myc

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

commit 95bdf1753b46d172f983ee41d1773e283826ab55
parent 41803c15495a4663662ffa8c9a72be9f0534a599
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 18:34:16 +0000

config: load runtime settings from env files

- replace the repo-root config contract with explicit .env parsing and validation
- add .env.example and update the cli, tests, and discovery docs to use --env-file
- remove the legacy toml example and dependency while tightening config parse errors
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, and the repo-root cargo test --locked lane on the reconciled tree

Diffstat:
A.env.example | 33+++++++++++++++++++++++++++++++++
M.gitignore | 1+
MCargo.lock | 1-
MCargo.toml | 1-
Dconfig.example.toml | 40----------------------------------------
Msrc/cli.rs | 12++++++------
Msrc/config.rs | 380++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/error.rs | 6+++---
Msrc/lib.rs | 4++--
Mtests/discovery_cli.rs | 191++++++++++++++++++++++++++++++++++++-------------------------------------------
10 files changed, 430 insertions(+), 239 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,33 @@ +# explicit runtime contract for myc +# copy to `.env` for local runs and replace paths/relays/domains with real values + +MYC_SERVICE_INSTANCE_NAME=myc +MYC_LOGGING_FILTER=info,myc=info + +MYC_PATHS_STATE_DIR=var +MYC_PATHS_SIGNER_IDENTITY_PATH=identity.json +MYC_PATHS_USER_IDENTITY_PATH=user-identity.json + +MYC_AUDIT_DEFAULT_READ_LIMIT=200 +MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 +MYC_AUDIT_MAX_ARCHIVED_FILES=8 + +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=signer.example.com +MYC_DISCOVERY_HANDLER_IDENTIFIER=myc +MYC_DISCOVERY_APP_IDENTITY_PATH=app-identity.json +MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.example.com +MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.example.com +MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE=https://signer.example.com/connect?uri=<nostrconnect> +MYC_DISCOVERY_NIP05_OUTPUT_PATH=public/.well-known/nostr.json +MYC_DISCOVERY_METADATA_NAME=myc +MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza +MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer +MYC_DISCOVERY_METADATA_WEBSITE=https://signer.example.com +MYC_DISCOVERY_METADATA_PICTURE=https://signer.example.com/logo.png + +MYC_POLICY_CONNECTION_APPROVAL=explicit_user + +MYC_TRANSPORT_ENABLED=true +MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 +MYC_TRANSPORT_RELAYS=wss://relay.example.com diff --git a/.gitignore b/.gitignore @@ -3,6 +3,7 @@ # Local environment files .env .env.* +!.env.example # OS and editor files .DS_Store diff --git a/Cargo.lock b/Cargo.lock @@ -1044,7 +1044,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite", - "toml", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml @@ -24,7 +24,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } -toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } url = "2.5" diff --git a/config.example.toml b/config.example.toml @@ -1,40 +0,0 @@ -[service] -instance_name = "myc" - -[logging] -filter = "info,myc=info" - -[paths] -state_dir = "var" -signer_identity_path = "identity.json" -user_identity_path = "user-identity.json" - -[audit] -default_read_limit = 200 -max_active_file_bytes = 262144 -max_archived_files = 8 - -[discovery] -enabled = true -domain = "signer.example.com" -handler_identifier = "myc" -app_identity_path = "app-identity.json" -public_relays = ["wss://relay.example.com"] -publish_relays = ["wss://relay.example.com"] -nostrconnect_url_template = "https://signer.example.com/connect?uri=<nostrconnect>" -nip05_output_path = "public/.well-known/nostr.json" - -[discovery.metadata] -name = "myc" -display_name = "Mycorrhiza" -about = "NIP-46 signer" -website = "https://signer.example.com" -picture = "https://signer.example.com/logo.png" - -[policy] -connection_approval = "explicit_user" - -[transport] -enabled = true -connect_timeout_secs = 10 -relays = ["wss://relay.example.com"] diff --git a/src/cli.rs b/src/cli.rs @@ -11,7 +11,7 @@ use serde::Serialize; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; -use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; +use crate::config::{DEFAULT_ENV_PATH, MycConfig}; use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; use crate::discovery::{ MycDiscoveryContext, MycDiscoveryRepairSummary, diff_live_nip89, fetch_live_nip89, @@ -24,8 +24,8 @@ use crate::logging; #[command(name = "myc")] #[command(about = "Mycorrhiza NIP-46 signer service")] pub struct MycCli { - #[arg(long, global = true)] - config: Option<PathBuf>, + #[arg(long = "env-file", global = true)] + env_file: Option<PathBuf>, #[command(subcommand)] command: Option<MycCommand>, } @@ -257,7 +257,7 @@ pub enum MycDiscoveryRepairAttemptOutput { pub async fn run_from_env() -> Result<(), MycError> { let cli = MycCli::parse(); - let config = load_config(cli.config.as_deref())?; + let config = load_config(cli.env_file.as_deref())?; match cli.command.unwrap_or(MycCommand::Run) { MycCommand::Run => { @@ -433,8 +433,8 @@ pub async fn run_from_env() -> Result<(), MycError> { fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> { match path { - Some(path) => MycConfig::load_from_path_if_exists(path), - None => MycConfig::load_from_path_if_exists(DEFAULT_CONFIG_PATH), + Some(path) => MycConfig::load_from_env_path(path), + None => MycConfig::load_from_env_path(DEFAULT_ENV_PATH), } } diff --git a/src/config.rs b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; @@ -9,7 +10,7 @@ use tracing_subscriber::EnvFilter; use crate::error::MycError; -pub const DEFAULT_CONFIG_PATH: &str = "config.toml"; +pub const DEFAULT_ENV_PATH: &str = ".env"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -202,32 +203,21 @@ impl MycConnectionApproval { } impl MycConfig { - pub fn load_from_default_path_if_exists() -> Result<Self, MycError> { - Self::load_from_path_if_exists(DEFAULT_CONFIG_PATH) + pub fn load_from_default_env_path() -> Result<Self, MycError> { + Self::load_from_env_path(DEFAULT_ENV_PATH) } - pub fn load_from_path_if_exists(path: impl AsRef<Path>) -> Result<Self, MycError> { - let path = path.as_ref(); - if !path.exists() { - let config = Self::default(); - config.validate()?; - return Ok(config); - } - - Self::load_from_path(path) - } - - pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, MycError> { + pub fn load_from_env_path(path: impl AsRef<Path>) -> Result<Self, MycError> { let path = path.as_ref(); let value = fs::read_to_string(path).map_err(|source| MycError::ConfigIo { path: path.to_path_buf(), source, })?; - Self::from_toml_str_with_source(&value, path) + Self::from_env_str_with_source(&value, path) } - pub fn from_toml_str(value: &str) -> Result<Self, MycError> { - Self::from_toml_str_with_source(value, Path::new("<inline>")) + pub fn from_env_str(value: &str) -> Result<Self, MycError> { + Self::from_env_str_with_source(value, Path::new("<inline>")) } pub fn validate(&self) -> Result<(), MycError> { @@ -298,16 +288,261 @@ impl MycConfig { Ok(()) } - fn from_toml_str_with_source(value: &str, path: &Path) -> Result<Self, MycError> { - let config: Self = toml::from_str(value).map_err(|source| MycError::ConfigParse { - path: path.to_path_buf(), - source, - })?; + fn from_env_str_with_source(value: &str, path: &Path) -> Result<Self, MycError> { + let entries = parse_env_entries(value, path)?; + let mut config = Self::default(); + for (key, value, line_number) in entries { + apply_env_entry(&mut config, key.as_str(), value.as_str(), path, line_number)?; + } config.validate()?; Ok(config) } } +fn parse_env_entries(value: &str, path: &Path) -> Result<Vec<(String, String, usize)>, MycError> { + let mut seen = BTreeSet::new(); + let mut entries = Vec::new(); + + for (index, raw_line) in value.lines().enumerate() { + let line_number = index + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some((key_raw, value_raw)) = raw_line.split_once('=') else { + return Err(config_parse_error( + path, + line_number, + "expected KEY=VALUE assignment", + )); + }; + let key = key_raw.trim(); + if key.is_empty() { + return Err(config_parse_error( + path, + line_number, + "environment variable name must not be empty", + )); + } + if !key.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) { + return Err(config_parse_error( + path, + line_number, + format!("invalid environment variable name `{key}`"), + )); + } + if !seen.insert(key.to_owned()) { + return Err(config_parse_error( + path, + line_number, + format!("duplicate environment variable `{key}`"), + )); + } + entries.push(( + key.to_owned(), + parse_env_value(value_raw.trim(), path, line_number)?, + line_number, + )); + } + + Ok(entries) +} + +fn parse_env_value(value: &str, path: &Path, line_number: usize) -> Result<String, MycError> { + if value.starts_with('"') || value.starts_with('\'') { + let quote = value.chars().next().expect("quoted env value prefix"); + if !value.ends_with(quote) || value.len() < 2 { + return Err(config_parse_error( + path, + line_number, + "unterminated quoted environment value", + )); + } + return Ok(value[1..value.len() - 1].to_owned()); + } + Ok(value.to_owned()) +} + +fn apply_env_entry( + config: &mut MycConfig, + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<(), MycError> { + match key { + "MYC_SERVICE_INSTANCE_NAME" => config.service.instance_name = value.to_owned(), + "MYC_LOGGING_FILTER" => config.logging.filter = value.to_owned(), + "MYC_PATHS_STATE_DIR" => config.paths.state_dir = PathBuf::from(value), + "MYC_PATHS_SIGNER_IDENTITY_PATH" => { + config.paths.signer_identity_path = PathBuf::from(value); + } + "MYC_PATHS_USER_IDENTITY_PATH" => { + config.paths.user_identity_path = PathBuf::from(value); + } + "MYC_AUDIT_DEFAULT_READ_LIMIT" => { + config.audit.default_read_limit = parse_usize_env(key, value, path, line_number)?; + } + "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES" => { + config.audit.max_active_file_bytes = parse_u64_env(key, value, path, line_number)?; + } + "MYC_AUDIT_MAX_ARCHIVED_FILES" => { + config.audit.max_archived_files = parse_usize_env(key, value, path, line_number)?; + } + "MYC_DISCOVERY_ENABLED" => { + config.discovery.enabled = parse_bool_env(key, value, path, line_number)?; + } + "MYC_DISCOVERY_DOMAIN" => { + config.discovery.domain = parse_optional_string_env(value); + } + "MYC_DISCOVERY_HANDLER_IDENTIFIER" => { + config.discovery.handler_identifier = value.to_owned(); + } + "MYC_DISCOVERY_APP_IDENTITY_PATH" => { + config.discovery.app_identity_path = parse_optional_path_env(value); + } + "MYC_DISCOVERY_PUBLIC_RELAYS" => { + config.discovery.public_relays = parse_string_list_env(value); + } + "MYC_DISCOVERY_PUBLISH_RELAYS" => { + config.discovery.publish_relays = parse_string_list_env(value); + } + "MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE" => { + config.discovery.nostrconnect_url_template = parse_optional_string_env(value); + } + "MYC_DISCOVERY_NIP05_OUTPUT_PATH" => { + config.discovery.nip05_output_path = parse_optional_path_env(value); + } + "MYC_DISCOVERY_METADATA_NAME" => { + config.discovery.metadata.name = parse_optional_string_env(value); + } + "MYC_DISCOVERY_METADATA_DISPLAY_NAME" => { + config.discovery.metadata.display_name = parse_optional_string_env(value); + } + "MYC_DISCOVERY_METADATA_ABOUT" => { + config.discovery.metadata.about = parse_optional_string_env(value); + } + "MYC_DISCOVERY_METADATA_WEBSITE" => { + config.discovery.metadata.website = parse_optional_string_env(value); + } + "MYC_DISCOVERY_METADATA_PICTURE" => { + config.discovery.metadata.picture = parse_optional_string_env(value); + } + "MYC_POLICY_CONNECTION_APPROVAL" => { + config.policy.connection_approval = + parse_connection_approval_env(key, value, path, line_number)?; + } + "MYC_TRANSPORT_ENABLED" => { + config.transport.enabled = parse_bool_env(key, value, path, line_number)?; + } + "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS" => { + config.transport.connect_timeout_secs = parse_u64_env(key, value, path, line_number)?; + } + "MYC_TRANSPORT_RELAYS" => { + config.transport.relays = parse_string_list_env(value); + } + _ => { + return Err(config_parse_error( + path, + line_number, + format!("unknown environment variable `{key}`"), + )); + } + } + + Ok(()) +} + +fn parse_bool_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<bool, MycError> { + value.parse::<bool>().map_err(|_| { + config_parse_error( + path, + line_number, + format!("{key} must be `true` or `false`"), + ) + }) +} + +fn parse_usize_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<usize, MycError> { + value.parse::<usize>().map_err(|_| { + config_parse_error( + path, + line_number, + format!("{key} must be an unsigned integer"), + ) + }) +} + +fn parse_u64_env(key: &str, value: &str, path: &Path, line_number: usize) -> Result<u64, MycError> { + value.parse::<u64>().map_err(|_| { + config_parse_error( + path, + line_number, + format!("{key} must be an unsigned integer"), + ) + }) +} + +fn parse_connection_approval_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<MycConnectionApproval, MycError> { + match value { + "not_required" => Ok(MycConnectionApproval::NotRequired), + "explicit_user" => Ok(MycConnectionApproval::ExplicitUser), + _ => Err(config_parse_error( + path, + line_number, + format!("{key} must be `not_required` or `explicit_user`"), + )), + } +} + +fn parse_optional_string_env(value: &str) -> Option<String> { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_owned()) + } +} + +fn parse_optional_path_env(value: &str) -> Option<PathBuf> { + parse_optional_string_env(value).map(PathBuf::from) +} + +fn parse_string_list_env(value: &str) -> Vec<String> { + value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn config_parse_error(path: &Path, line_number: usize, message: impl Into<String>) -> MycError { + MycError::ConfigParse { + path: path.to_path_buf(), + line_number, + message: message.into(), + } +} + impl MycTransportConfig { pub fn parse_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> { self.relays @@ -513,49 +748,34 @@ mod tests { } #[test] - fn parse_config_from_toml_overrides_defaults() { - let config = MycConfig::from_toml_str( + fn parse_config_from_env_overrides_defaults() { + let config = MycConfig::from_env_str( r#" - [service] - instance_name = "myc-dev" - - [logging] - filter = "debug,myc=trace" - - [paths] - state_dir = "/tmp/myc" - signer_identity_path = "/tmp/myc-identity.json" - user_identity_path = "/tmp/myc-user.json" - - [audit] - default_read_limit = 50 - max_active_file_bytes = 4096 - max_archived_files = 3 - - [discovery] - enabled = true - domain = "myc.example.com" - handler_identifier = "myc-main" - app_identity_path = "/tmp/myc-app.json" - public_relays = ["wss://relay.discovery.example.com"] - publish_relays = ["wss://relay.publish.example.com"] - nostrconnect_url_template = "https://myc.example.com/connect/<nostrconnect>" - nip05_output_path = "/tmp/nostr.json" - - [discovery.metadata] - name = "myc" - display_name = "Mycorrhiza" - about = "NIP-46 signer" - website = "https://myc.example.com" - picture = "https://myc.example.com/logo.png" - - [policy] - connection_approval = "not_required" - - [transport] - enabled = true - connect_timeout_secs = 15 - relays = ["wss://relay.example.com", "wss://relay2.example.com"] +MYC_SERVICE_INSTANCE_NAME=myc-dev +MYC_LOGGING_FILTER=debug,myc=trace +MYC_PATHS_STATE_DIR=/tmp/myc +MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/myc-identity.json +MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json +MYC_AUDIT_DEFAULT_READ_LIMIT=50 +MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 +MYC_AUDIT_MAX_ARCHIVED_FILES=3 +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=myc.example.com +MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main +MYC_DISCOVERY_APP_IDENTITY_PATH=/tmp/myc-app.json +MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.discovery.example.com +MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.publish.example.com +MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect> +MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json +MYC_DISCOVERY_METADATA_NAME=myc +MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza +MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer +MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com +MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png +MYC_POLICY_CONNECTION_APPROVAL=not_required +MYC_TRANSPORT_ENABLED=true +MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 +MYC_TRANSPORT_RELAYS=wss://relay.example.com,wss://relay2.example.com "#, ) .expect("config"); @@ -618,24 +838,23 @@ mod tests { } #[test] - fn load_from_missing_path_returns_default_config() { + fn load_from_missing_env_path_fails() { let temp = tempfile::tempdir().expect("tempdir"); - let config = MycConfig::load_from_path_if_exists(temp.path().join("missing.toml")) - .expect("missing path fallback"); + let err = MycConfig::load_from_env_path(temp.path().join("missing.env")) + .expect_err("missing env"); - assert_eq!(config, MycConfig::default()); + assert!(err.to_string().contains("config io error")); } #[test] - fn parse_rejects_unknown_fields() { - let err = MycConfig::from_toml_str( + fn parse_rejects_unknown_env_keys() { + let err = MycConfig::from_env_str( r#" - [service] - instance_name = "myc-dev" - extra = "nope" +MYC_SERVICE_INSTANCE_NAME=myc-dev +MYC_UNKNOWN=nope "#, ) - .expect_err("unknown field"); + .expect_err("unknown key"); assert!(err.to_string().contains("config parse error")); } @@ -690,13 +909,12 @@ mod tests { } #[test] - fn example_config_parses_and_validates() { - let example = fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml"), - ) - .expect("read example config"); + fn example_env_parses_and_validates() { + let example = + fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example")) + .expect("read example config"); - let config = MycConfig::from_toml_str(&example).expect("example config"); + let config = MycConfig::from_env_str(&example).expect("example config"); assert_eq!(config.service.instance_name, "myc"); assert!(config.discovery.enabled); diff --git a/src/error.rs b/src/error.rs @@ -14,11 +14,11 @@ pub enum MycError { #[source] source: std::io::Error, }, - #[error("config parse error at {path}: {source}")] + #[error("config parse error at {path}:{line_number}: {message}")] ConfigParse { path: PathBuf, - #[source] - source: toml::de::Error, + line_number: usize, + message: String, }, #[error("invalid config: {0}")] InvalidConfig(String), diff --git a/src/lib.rs b/src/lib.rs @@ -16,7 +16,7 @@ pub use audit::{ MycOperationAuditStore, }; pub use config::{ - DEFAULT_CONFIG_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycDiscoveryConfig, + DEFAULT_ENV_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycLoggingConfig, MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig, }; @@ -36,7 +36,7 @@ pub use error::MycError; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; pub async fn run() -> Result<(), MycError> { - let config = MycConfig::load_from_default_path_if_exists()?; + let config = MycConfig::load_from_default_env_path()?; logging::init_logging(&config.logging)?; MycApp::bootstrap(config)?.run().await } diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -308,7 +308,7 @@ fn write_identity(path: &Path, secret_key: &str) { .expect("save identity"); } -fn write_config( +fn write_env_file( path: &Path, state_dir: &Path, signer_identity_path: &Path, @@ -316,52 +316,33 @@ fn write_config( app_identity_path: &Path, relay_urls: &[&str], ) { - let relay_list = relay_urls - .iter() - .map(|relay| format!("\"{relay}\"")) - .collect::<Vec<_>>() - .join(", "); - let config = format!( - r#"[service] -instance_name = "myc" - -[logging] -filter = "info,myc=info" - -[paths] -state_dir = "{state_dir}" -signer_identity_path = "{signer_identity_path}" -user_identity_path = "{user_identity_path}" - -[audit] -default_read_limit = 200 -max_active_file_bytes = 262144 -max_archived_files = 8 - -[discovery] -enabled = true -domain = "signer.example.com" -handler_identifier = "myc" -app_identity_path = "{app_identity_path}" -public_relays = [{relay_list}] -publish_relays = [{relay_list}] -nostrconnect_url_template = "https://signer.example.com/connect?uri=<nostrconnect>" -nip05_output_path = "{nip05_output_path}" - -[discovery.metadata] -name = "myc" -display_name = "Mycorrhiza" -about = "NIP-46 signer" -website = "https://signer.example.com" -picture = "https://signer.example.com/logo.png" - -[policy] -connection_approval = "explicit_user" - -[transport] -enabled = false -connect_timeout_secs = 10 -relays = [] + let relay_list = relay_urls.join(","); + let env_file = format!( + r#"MYC_SERVICE_INSTANCE_NAME=myc +MYC_LOGGING_FILTER=info,myc=info +MYC_PATHS_STATE_DIR={state_dir} +MYC_PATHS_SIGNER_IDENTITY_PATH={signer_identity_path} +MYC_PATHS_USER_IDENTITY_PATH={user_identity_path} +MYC_AUDIT_DEFAULT_READ_LIMIT=200 +MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 +MYC_AUDIT_MAX_ARCHIVED_FILES=8 +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=signer.example.com +MYC_DISCOVERY_HANDLER_IDENTIFIER=myc +MYC_DISCOVERY_APP_IDENTITY_PATH={app_identity_path} +MYC_DISCOVERY_PUBLIC_RELAYS={relay_list} +MYC_DISCOVERY_PUBLISH_RELAYS={relay_list} +MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE=https://signer.example.com/connect?uri=<nostrconnect> +MYC_DISCOVERY_NIP05_OUTPUT_PATH={nip05_output_path} +MYC_DISCOVERY_METADATA_NAME=myc +MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza +MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer +MYC_DISCOVERY_METADATA_WEBSITE=https://signer.example.com +MYC_DISCOVERY_METADATA_PICTURE=https://signer.example.com/logo.png +MYC_POLICY_CONNECTION_APPROVAL=explicit_user +MYC_TRANSPORT_ENABLED=false +MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 +MYC_TRANSPORT_RELAYS= "#, state_dir = state_dir.display(), signer_identity_path = signer_identity_path.display(), @@ -370,13 +351,13 @@ relays = [] relay_list = relay_list, nip05_output_path = state_dir.join("public/.well-known/nostr.json").display(), ); - fs::write(path, config).expect("write config"); + fs::write(path, env_file).expect("write env file"); } -fn run_myc(config_path: &Path, args: &[&str]) -> TestResult<Output> { +fn run_myc(env_path: &Path, args: &[&str]) -> TestResult<Output> { Ok(Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--config") - .arg(config_path) + .arg("--env-file") + .arg(env_path) .args(args) .output()?) } @@ -425,7 +406,7 @@ async fn publish_handler_event( #[test] fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -444,8 +425,8 @@ fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { &app_identity_path, "3333333333333333333333333333333333333333333333333333333333333333", ); - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -454,7 +435,7 @@ fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { ); let export = run_myc( - &config_path, + &env_path, &[ "discovery", "export-bundle", @@ -475,7 +456,7 @@ fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { assert!(bundle_dir.join("nip89-handler.json").exists()); let verify = run_myc( - &config_path, + &env_path, &[ "discovery", "verify-bundle", @@ -507,7 +488,7 @@ fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { let relay = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -525,8 +506,8 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -534,7 +515,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { &[relay.url()], ); - let inspect_missing = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?; + let inspect_missing = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; assert!( inspect_missing.status.success(), "inspect-live-nip89 failed: {}", @@ -556,7 +537,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { 1 ); - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( refresh.status.success(), "refresh-nip89 failed: {}", @@ -570,7 +551,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { .wait_for_published_events_by_author(app_identity.public_key(), 1) .await?; - let inspect_live = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?; + let inspect_live = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; assert!( inspect_live.status.success(), "inspect-live-nip89 after refresh failed: {}", @@ -589,7 +570,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { 1 ); - let diff = run_myc(&config_path, &["discovery", "diff-live-nip89"])?; + let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; assert!( diff.status.success(), "diff-live-nip89 failed: {}", @@ -621,7 +602,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { let relay = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -639,8 +620,8 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -665,7 +646,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { .wait_for_published_events_by_author(app_identity.public_key(), 2) .await?; - let diff = run_myc(&config_path, &["discovery", "diff-live-nip89"])?; + let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; assert!( diff.status.success(), "diff-live-nip89 failed: {}", @@ -688,7 +669,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { .is_empty() ); - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( !refresh.status.success(), "refresh-nip89 unexpectedly succeeded: {}", @@ -716,7 +697,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { ]) ); let attempt = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt", @@ -755,7 +736,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { Value::Array(vec![Value::String(relay.url().to_owned())]) ); - let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; + let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; assert!( forced_refresh.status.success(), "refresh-nip89 --force failed: {}", @@ -773,7 +754,7 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T let relay_a = TestRelay::spawn().await?; let relay_b = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -791,8 +772,8 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -807,7 +788,7 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T .queue_publish_outcomes(app_identity.public_key(), &[false]) .await; - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( refresh.status.success(), "refresh-nip89 failed: {}", @@ -839,7 +820,7 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T 0 ); - let audit_summary = run_myc(&config_path, &["audit", "summary", "--scope", "operation"])?; + let audit_summary = run_myc(&env_path, &["audit", "summary", "--scope", "operation"])?; assert!( audit_summary.status.success(), "audit summary failed: {}", @@ -874,7 +855,7 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> TestResult<()> { let relay = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -892,8 +873,8 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -905,7 +886,7 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> .queue_publish_outcomes(app_identity.public_key(), &[false]) .await; - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( !refresh.status.success(), "refresh-nip89 unexpectedly succeeded: {}", @@ -924,7 +905,7 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> ); let attempt = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt", @@ -973,7 +954,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> let relay_a = TestRelay::spawn().await?; let relay_b = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -991,8 +972,8 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -1007,7 +988,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> .queue_publish_outcomes(app_identity.public_key(), &[false, true]) .await; - let first_refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let first_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( first_refresh.status.success(), "first refresh-nip89 failed: {}", @@ -1029,7 +1010,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> .wait_for_published_events_by_author(app_identity.public_key(), 1) .await?; - let second_refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let second_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( second_refresh.status.success(), "second refresh-nip89 failed: {}", @@ -1049,7 +1030,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> Value::Array(vec![]) ); - let latest_attempt = run_myc(&config_path, &["audit", "latest-discovery-repair"])?; + let latest_attempt = run_myc(&env_path, &["audit", "latest-discovery-repair"])?; assert!( latest_attempt.status.success(), "latest-discovery-repair failed: {}", @@ -1077,7 +1058,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> ); let first_attempt_summary = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt", @@ -1115,7 +1096,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> ); let first_attempt_records = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt", @@ -1153,7 +1134,7 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul let relay_a = TestRelay::spawn().await?; let relay_b = TestRelay::spawn().await?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -1174,8 +1155,8 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -1224,7 +1205,7 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul .wait_for_published_events_by_author(app_identity.public_key(), 1) .await?; - let inspect = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?; + let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; assert!( inspect.status.success(), "inspect-live-nip89 failed: {}", @@ -1257,7 +1238,7 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul .any(|relays| relays == &vec![relay_b.url().to_owned()]) ); - let diff = run_myc(&config_path, &["discovery", "diff-live-nip89"])?; + let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; assert!( diff.status.success(), "diff-live-nip89 failed: {}", @@ -1295,7 +1276,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th let relay = TestRelay::spawn().await?; let unavailable_relay = unavailable_relay_url()?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -1313,8 +1294,8 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th "2222222222222222222222222222222222222222222222222222222222222222", ); app_identity.save_json(&app_identity_path)?; - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -1322,7 +1303,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th &[relay.url(), unavailable_relay.as_str()], ); - let inspect = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?; + let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; assert!( inspect.status.success(), "inspect-live-nip89 failed: {}", @@ -1344,7 +1325,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th }) ); - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( !refresh.status.success(), "refresh-nip89 unexpectedly succeeded: {}", @@ -1363,7 +1344,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th Value::String(attempt_id.to_owned()) ); let attempt = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt", @@ -1402,7 +1383,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th Value::Array(vec![Value::String(relay.url().to_owned())]) ); - let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?; + let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; assert!( forced_refresh.status.success(), "refresh-nip89 --force failed: {}", @@ -1424,7 +1405,7 @@ async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavaila -> TestResult<()> { let unavailable_relay = unavailable_relay_url()?; let temp = tempfile::tempdir()?; - let config_path = temp.path().join("config.toml"); + let env_path = temp.path().join(".env"); let state_dir = temp.path().join("state"); let signer_identity_path = temp.path().join("signer.json"); let user_identity_path = temp.path().join("user.json"); @@ -1442,8 +1423,8 @@ async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavaila &app_identity_path, "3333333333333333333333333333333333333333333333333333333333333333", ); - write_config( - &config_path, + write_env_file( + &env_path, &state_dir, &signer_identity_path, &user_identity_path, @@ -1451,7 +1432,7 @@ async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavaila &[unavailable_relay.as_str()], ); - let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?; + let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; assert!( !refresh.status.success(), "refresh-nip89 unexpectedly succeeded: {}", @@ -1470,7 +1451,7 @@ async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavaila ); let attempt = run_myc( - &config_path, + &env_path, &[ "audit", "discovery-repair-attempt",