Skip to main content

ma_core/config/
mod.rs

1//! Configuration for ma-core-based daemons.
2//!
3//! Provides [`Config`], a configuration model that supports:
4//!
5//! - native daemon bootstrapping from CLI/env/YAML/defaults via
6//!   [`Config::from_args`]
7//! - storage-agnostic serialization workflows (including wasm) via
8//!   [`Config::from_yaml_str`] and [`Config::to_yaml_string`]
9//!
10//! Native `from_args` resolves fields from (in decreasing priority):
11//!
12//! 1. Explicit CLI arguments (via [`MaArgs`])
13//! 2. `MA_<MA_DEFAULT_SLUG>_*` environment variables (slug-prefixed, set per binary)
14//! 3. `MA_*` environment variables (static fallback, shared across binaries)
15//! 4. YAML config file (`XDG_CONFIG_HOME/ma/<slug>.yaml`)
16//! 5. Built-in defaults
17//!
18//! # Native compile-time constant requirement
19//!
20//! Binaries using [`Config::from_args`] **must** declare a compile-time
21//! constant:
22//!
23//! ```no_run
24//! const MA_DEFAULT_SLUG: &str = "panteia";
25//! ```
26//!
27//! This constant serves a dual purpose:
28//! - **Default slug** — used for file naming when `--slug` is not set.
29//! - **Env-var prefix** — uppercased to `MA_PANTEIA_*` for env-var lookup.
30//!   This prefix is fixed at compile time and cannot be changed at runtime.
31//!   Only file-naming can be overridden via `--slug`.
32
33#[cfg(not(target_arch = "wasm32"))]
34pub mod cli;
35#[cfg(not(target_arch = "wasm32"))]
36mod logging;
37#[cfg(target_arch = "wasm32")]
38mod logging_wasm;
39pub mod secrets;
40
41#[cfg(not(target_arch = "wasm32"))]
42pub use cli::MaArgs;
43pub use secrets::SecretBundle;
44
45#[cfg(target_arch = "wasm32")]
46use std::path::PathBuf;
47#[cfg(not(target_arch = "wasm32"))]
48use std::path::{Path, PathBuf};
49
50use crate::error::{Error, Result};
51use base64::engine::general_purpose::STANDARD as B64;
52use base64::Engine;
53use serde::{Deserialize, Serialize};
54
55// ─── Defaults ────────────────────────────────────────────────────────────────
56
57const DEFAULT_LOG_LEVEL: &str = "info";
58const DEFAULT_LOG_LEVEL_STDOUT: &str = "warn";
59const DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS: u64 = 60;
60const DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS: u64 = 10;
61#[cfg(not(target_arch = "wasm32"))]
62const DEFAULT_KUBO_RPC_URL: &str = "http://127.0.0.1:5001";
63
64// ─── Config struct ───────────────────────────────────────────────────────────
65
66/// Runtime configuration for a ma daemon.
67///
68/// Build via [`Config::from_args`] on native targets or via YAML/string
69/// serialization helpers on wasm.
70#[derive(Debug, Clone)]
71pub struct Config {
72    /// Short printable slug identifying this daemon instance.
73    /// Used in default file names: `<slug>.yaml`, `<slug>.bin`, `<slug>.log`.
74    pub slug: String,
75
76    /// Log level written to the log file (e.g. `"info"`, `"debug"`).
77    pub log_level: String,
78
79    /// Log level written to stdout.
80    pub log_level_stdout: String,
81
82    /// Cache TTL (seconds) for successful DID document resolutions.
83    /// Set to `0` to disable positive cache entries.
84    pub did_resolver_positive_ttl_secs: u64,
85
86    /// Cache TTL (seconds) for failed DID document resolutions.
87    /// Set to `0` to disable negative cache entries.
88    pub did_resolver_negative_ttl_secs: u64,
89
90    /// Path to the log file. `None` → resolved to `XDG_DATA_HOME/ma/<slug>.log`
91    /// on first use.
92    pub log_file: Option<PathBuf>,
93
94    #[cfg(not(target_arch = "wasm32"))]
95    /// Kubo JSON-RPC API URL.
96    pub kubo_rpc_url: String,
97
98    #[cfg(not(target_arch = "wasm32"))]
99    /// IPNS key alias registered in Kubo for this daemon.
100    pub kubo_key_alias: String,
101
102    /// Path to the encrypted secret bundle. `None` → `XDG_CONFIG_HOME/ma/<slug>.bin`.
103    pub secret_bundle: Option<PathBuf>,
104
105    /// Passphrase to unlock the secret bundle.
106    /// In headless configs this is stored in cleartext in the YAML file.
107    pub secret_bundle_passphrase: Option<String>,
108
109    /// Path where this config was loaded from or will be saved to.
110    pub config_path: Option<PathBuf>,
111
112    /// Extra user-defined YAML keys that are not part of the core schema.
113    /// Preserved during load and save so callers can extend the config freely.
114    pub extra: serde_yaml::Mapping,
115}
116
117/// Browser-friendly identity export payload.
118///
119/// Contains serialized config text and an encrypted secret bundle encoded as
120/// base64 so it can be stored or copied as plain JSON.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BrowserIdentityExport {
123    pub version: u8,
124    pub config_yaml: String,
125    pub encrypted_secret_bundle_base64: String,
126}
127
128impl BrowserIdentityExport {
129    pub fn new(config_yaml: String, encrypted_secret_bundle: &[u8]) -> Self {
130        Self {
131            version: 1,
132            config_yaml,
133            encrypted_secret_bundle_base64: B64.encode(encrypted_secret_bundle),
134        }
135    }
136
137    pub fn encrypted_secret_bundle_bytes(&self) -> Result<Vec<u8>> {
138        B64.decode(self.encrypted_secret_bundle_base64.as_bytes())
139            .map_err(|e| Error::Config(format!("invalid encrypted bundle base64: {e}")))
140    }
141
142    pub fn to_json_string(&self) -> Result<String> {
143        serde_json::to_string(self)
144            .map_err(|e| Error::Config(format!("failed to serialize browser export: {e}")))
145    }
146
147    pub fn from_json_str(json: &str) -> Result<Self> {
148        serde_json::from_str(json)
149            .map_err(|e| Error::Config(format!("failed to parse browser export JSON: {e}")))
150    }
151}
152
153// ─── XDG path helpers ────────────────────────────────────────────────────────
154
155#[cfg(not(target_arch = "wasm32"))]
156fn project_dirs() -> Result<directories::ProjectDirs> {
157    directories::ProjectDirs::from("", "ma", "ma")
158        .ok_or_else(|| Error::Config("cannot determine XDG base directories".to_string()))
159}
160
161/// Default YAML config path: `XDG_CONFIG_HOME/ma/<slug>.yaml`.
162#[cfg(not(target_arch = "wasm32"))]
163pub fn default_config_path(slug: &str) -> Result<PathBuf> {
164    Ok(project_dirs()?.config_dir().join(format!("{slug}.yaml")))
165}
166
167/// Default secret bundle path: `XDG_CONFIG_HOME/ma/<slug>.bin`.
168#[cfg(not(target_arch = "wasm32"))]
169pub fn default_secret_bundle_path(slug: &str) -> Result<PathBuf> {
170    Ok(project_dirs()?.config_dir().join(format!("{slug}.bin")))
171}
172
173/// Default log file path: `XDG_DATA_HOME/ma/<slug>.log`.
174#[cfg(not(target_arch = "wasm32"))]
175pub fn default_log_file_path(slug: &str) -> Result<PathBuf> {
176    Ok(project_dirs()?.data_dir().join(format!("{slug}.log")))
177}
178
179// ─── Secure file I/O ─────────────────────────────────────────────────────────
180
181/// Write `data` to `path`, creating parent directories as needed.
182///
183/// On Unix the file is created (or truncated) with mode `0600`. On other
184/// platforms the file is written without special permission handling.
185#[cfg(not(target_arch = "wasm32"))]
186pub(crate) fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
187    use std::io::Write;
188
189    if let Some(parent) = path.parent() {
190        std::fs::create_dir_all(parent).map_err(|e| {
191            Error::Config(format!("failed to create dir {}: {e}", parent.display()))
192        })?;
193    }
194
195    #[cfg(unix)]
196    let mut file = {
197        use std::os::unix::fs::OpenOptionsExt;
198        std::fs::OpenOptions::new()
199            .write(true)
200            .create(true)
201            .truncate(true)
202            .mode(0o600)
203            .open(path)
204            .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?
205    };
206
207    #[cfg(not(unix))]
208    let mut file = std::fs::OpenOptions::new()
209        .write(true)
210        .create(true)
211        .truncate(true)
212        .open(path)
213        .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?;
214
215    file.write_all(data)
216        .map_err(|e| Error::Config(format!("failed to write {}: {e}", path.display())))?;
217
218    // Belt-and-suspenders: also set permissions after creation (handles the
219    // case where the file already existed with wider permissions).
220    #[cfg(unix)]
221    {
222        use std::os::unix::fs::PermissionsExt;
223        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
224            Error::Config(format!(
225                "failed to set permissions on {}: {e}",
226                path.display()
227            ))
228        })?;
229    }
230
231    Ok(())
232}
233
234/// Check that a file's permissions are not wider than `0600` and log a
235/// warning if they are. Only active on Unix.
236#[cfg(all(not(target_arch = "wasm32"), unix))]
237fn check_permissions(path: &Path) {
238    use std::os::unix::fs::MetadataExt;
239    if let Ok(meta) = std::fs::metadata(path) {
240        let mode = meta.mode() & 0o777;
241        if mode > 0o600 {
242            tracing::warn!(
243                path = %path.display(),
244                mode = format!("{mode:04o}"),
245                "config file has permissions wider than 0600 — consider `chmod 0600 {}`",
246                path.display()
247            );
248        }
249    }
250}
251
252#[cfg(all(not(target_arch = "wasm32"), not(unix)))]
253fn check_permissions(_path: &Path) {}
254
255// ─── YAML helpers ────────────────────────────────────────────────────────────
256
257#[cfg(not(target_arch = "wasm32"))]
258fn load_yaml_mapping(path: &Path) -> Result<serde_yaml::Mapping> {
259    let content = std::fs::read_to_string(path)
260        .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
261    let val: serde_yaml::Value = serde_yaml::from_str(&content)
262        .map_err(|e| Error::Config(format!("invalid YAML in {}: {e}", path.display())))?;
263    if let serde_yaml::Value::Mapping(m) = val {
264        Ok(m)
265    } else {
266        Err(Error::Config(format!(
267            "config file {} must be a YAML mapping",
268            path.display()
269        )))
270    }
271}
272
273#[cfg(not(target_arch = "wasm32"))]
274fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
275    m.get(serde_yaml::Value::String(key.to_string()))
276        .and_then(|v| v.as_str())
277        .map(String::from)
278}
279
280#[cfg(not(target_arch = "wasm32"))]
281fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
282    m.get(serde_yaml::Value::String(key.to_string()))
283        .and_then(|v| v.as_str())
284        .map(PathBuf::from)
285}
286
287#[cfg(not(target_arch = "wasm32"))]
288fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
289    m.get(serde_yaml::Value::String(key.to_string()))
290        .and_then(|v| match v {
291            serde_yaml::Value::Number(n) => n.as_u64(),
292            serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
293            _ => None,
294        })
295}
296
297// ─── Config impl ─────────────────────────────────────────────────────────────
298
299impl Config {
300    /// Construct a config value suitable for wasm/local storage workflows.
301    ///
302    /// This constructor is storage-agnostic and does not touch the filesystem.
303    pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
304        let slug = slug.as_ref().to_string();
305        Self {
306            slug: slug.clone(),
307            log_level: DEFAULT_LOG_LEVEL.to_string(),
308            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
309            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
310            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
311            log_file: None,
312            #[cfg(not(target_arch = "wasm32"))]
313            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
314            #[cfg(not(target_arch = "wasm32"))]
315            kubo_key_alias: slug,
316            secret_bundle: None,
317            secret_bundle_passphrase: None,
318            config_path: None,
319            extra: serde_yaml::Mapping::new(),
320        }
321    }
322
323    /// Deserialize a config value from YAML text without filesystem I/O.
324    pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
325        let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
326            .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
327        let mut m = match val {
328            serde_yaml::Value::Mapping(m) => m,
329            _ => {
330                return Err(Error::Config(
331                    "config YAML must be a mapping at the top level".to_string(),
332                ));
333            }
334        };
335
336        let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
337            map.remove(serde_yaml::Value::String(key.to_string()))
338                .and_then(|v| v.as_str().map(ToOwned::to_owned))
339        };
340
341        let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
342            map.remove(serde_yaml::Value::String(key.to_string()))
343                .and_then(|v| v.as_str().map(PathBuf::from))
344        };
345
346        let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
347            map.remove(serde_yaml::Value::String(key.to_string()))
348                .and_then(|v| match v {
349                    serde_yaml::Value::Number(n) => n.as_u64(),
350                    serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
351                    _ => None,
352                })
353        };
354
355        let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
356        let log_level =
357            take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
358        let log_level_stdout = take_str(&mut m, "log_level_stdout")
359            .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
360        let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
361            .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
362        let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
363            .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
364        // `config_path` is runtime state and should never be restored from YAML.
365        let _ignored_config_path = take_path(&mut m, "config_path");
366        #[cfg(not(target_arch = "wasm32"))]
367        let kubo_rpc_url =
368            take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
369        #[cfg(not(target_arch = "wasm32"))]
370        let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
371
372        Ok(Self {
373            slug,
374            log_level,
375            log_level_stdout,
376            did_resolver_positive_ttl_secs,
377            did_resolver_negative_ttl_secs,
378            log_file: take_path(&mut m, "log_file"),
379            #[cfg(not(target_arch = "wasm32"))]
380            kubo_rpc_url,
381            #[cfg(not(target_arch = "wasm32"))]
382            kubo_key_alias,
383            secret_bundle: take_path(&mut m, "secret_bundle"),
384            secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
385            config_path: None,
386            extra: m,
387        })
388    }
389
390    /// Serialize config to YAML text without filesystem I/O.
391    pub fn to_yaml_string(&self) -> Result<String> {
392        let mut m = self.extra.clone();
393
394        let mut set = |k: &str, v: serde_yaml::Value| {
395            m.insert(serde_yaml::Value::String(k.to_string()), v);
396        };
397
398        set("slug", serde_yaml::Value::String(self.slug.clone()));
399        set(
400            "log_level",
401            serde_yaml::Value::String(self.log_level.clone()),
402        );
403        set(
404            "log_level_stdout",
405            serde_yaml::Value::String(self.log_level_stdout.clone()),
406        );
407        set(
408            "did_resolver_positive_ttl_secs",
409            serde_yaml::Value::Number(serde_yaml::Number::from(
410                self.did_resolver_positive_ttl_secs,
411            )),
412        );
413        set(
414            "did_resolver_negative_ttl_secs",
415            serde_yaml::Value::Number(serde_yaml::Number::from(
416                self.did_resolver_negative_ttl_secs,
417            )),
418        );
419        #[cfg(not(target_arch = "wasm32"))]
420        set(
421            "kubo_rpc_url",
422            serde_yaml::Value::String(self.kubo_rpc_url.clone()),
423        );
424        #[cfg(not(target_arch = "wasm32"))]
425        set(
426            "kubo_key_alias",
427            serde_yaml::Value::String(self.kubo_key_alias.clone()),
428        );
429
430        if let Some(ref p) = self.log_file {
431            set(
432                "log_file",
433                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
434            );
435        }
436        if let Some(ref p) = self.secret_bundle {
437            set(
438                "secret_bundle",
439                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
440            );
441        }
442        if let Some(ref pw) = self.secret_bundle_passphrase {
443            set(
444                "secret_bundle_passphrase",
445                serde_yaml::Value::String(pw.clone()),
446            );
447        }
448
449        serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
450            .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
451    }
452
453    /// Serialize config to YAML text while excluding secret passphrase fields.
454    ///
455    /// Useful for browser storage where passphrases should be provided by
456    /// runtime user input instead of persisted state.
457    pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
458        let mut copy = self.clone();
459        copy.secret_bundle_passphrase = None;
460        copy.to_yaml_string()
461    }
462
463    #[cfg(not(target_arch = "wasm32"))]
464    /// Build a `Config` by merging CLI arguments, environment variables, a
465    /// YAML config file, and built-in defaults.
466    ///
467    /// # Required compile-time constant
468    ///
469    /// Callers **MUST** pass a compile-time constant `MA_DEFAULT_SLUG: &'static str`.
470    /// This determines BOTH the default slug for file naming AND the fixed
471    /// env-var prefix `MA_<MA_DEFAULT_SLUG>_*`. The prefix cannot be changed
472    /// at runtime; only file naming may be overridden via `--slug`.
473    ///
474    /// ```
475    /// # #[cfg(all(feature = "config", not(target_arch = "wasm32")))]
476    /// # {
477    /// use ma_core::config::{Config, MaArgs};
478    /// let args = MaArgs::default();
479    /// let config = Config::from_args(&args, "doctest")?;
480    /// assert_eq!(config.slug, "doctest");
481    /// # }
482    /// # Ok::<(), ma_core::Error>(())
483    /// ```
484    ///
485    /// # Priority
486    ///
487    /// For each field the resolution order is:
488    /// 1. Explicit CLI argument
489    /// 2. `MA_<MA_DEFAULT_SLUG>_FIELD` environment variable
490    /// 3. `MA_FIELD` environment variable (static fallback)
491    /// 4. Value from the YAML config file
492    /// 5. Built-in default
493    #[allow(clippy::too_many_lines)]
494    pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
495        // The env-var prefix is determined by the compile-time constant.
496        // e.g. default_slug = "panteia"  →  prefix = "PANTEIA"
497        let prefix = default_slug.to_uppercase().replace('-', "_");
498
499        // Slug: CLI/env via clap (MA_SLUG) → compile-time default.
500        let slug = args
501            .slug
502            .clone()
503            .unwrap_or_else(|| default_slug.to_string());
504
505        // Config file path: explicit → slug-based XDG default.
506        let config_path = if let Some(ref p) = args.config {
507            p.clone()
508        } else {
509            default_config_path(&slug)?
510        };
511
512        // Load YAML if the file exists.
513        let yaml = if config_path.exists() {
514            check_permissions(&config_path);
515            Some(load_yaml_mapping(&config_path)?)
516        } else {
517            None
518        };
519
520        // Helper: resolve a string field through the priority chain.
521        // NOTE: closures borrow `yaml` and `prefix` immutably; NLL ensures
522        // the borrows end before we move `yaml` below.
523        let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
524            cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
525                .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
526                .or_else(|| {
527                    yaml.as_ref()
528                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
529                })
530                .unwrap_or_else(|| default.to_string())
531        };
532
533        let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
534            cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
535                .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
536                .or_else(|| {
537                    yaml.as_ref()
538                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
539                })
540        };
541
542        let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
543            cli.or_else(|| {
544                std::env::var(format!("MA_{prefix}_{env_key}"))
545                    .ok()
546                    .map(PathBuf::from)
547            })
548            .or_else(|| {
549                std::env::var(format!("MA_{env_key}"))
550                    .ok()
551                    .map(PathBuf::from)
552            })
553            .or_else(|| {
554                yaml.as_ref()
555                    .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
556            })
557        };
558
559        let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
560            cli.or_else(|| {
561                std::env::var(format!("MA_{prefix}_{env_key}"))
562                    .ok()
563                    .and_then(|v| v.parse::<u64>().ok())
564            })
565            .or_else(|| {
566                std::env::var(format!("MA_{env_key}"))
567                    .ok()
568                    .and_then(|v| v.parse::<u64>().ok())
569            })
570            .or_else(|| {
571                yaml.as_ref()
572                    .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
573            })
574            .unwrap_or(default)
575        };
576
577        let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
578        let log_level_stdout = resolve_str(
579            args.log_level_stdout.clone(),
580            "LOG_LEVEL_STDOUT",
581            DEFAULT_LOG_LEVEL_STDOUT,
582        );
583        let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
584        let did_resolver_positive_ttl_secs = resolve_u64(
585            args.did_resolver_positive_ttl_secs,
586            "DID_RESOLVER_POSITIVE_TTL_SECS",
587            DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
588        );
589        let did_resolver_negative_ttl_secs = resolve_u64(
590            args.did_resolver_negative_ttl_secs,
591            "DID_RESOLVER_NEGATIVE_TTL_SECS",
592            DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
593        );
594        let kubo_rpc_url = resolve_str(
595            args.kubo_rpc_url.clone(),
596            "KUBO_RPC_URL",
597            DEFAULT_KUBO_RPC_URL,
598        );
599        let kubo_key_alias =
600            resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
601        let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
602        let secret_bundle_passphrase = resolve_opt_str(
603            args.secret_bundle_passphrase.clone(),
604            "SECRET_BUNDLE_PASSPHRASE",
605        );
606
607        // Extra: all YAML keys that are not part of the core schema.
608        let known: &[&str] = &[
609            "slug",
610            "log_level",
611            "log_level_stdout",
612            "log_file",
613            "did_resolver_positive_ttl_secs",
614            "did_resolver_negative_ttl_secs",
615            "kubo_rpc_url",
616            "kubo_key_alias",
617            "secret_bundle",
618            "secret_bundle_passphrase",
619            // Legacy key; ignored and never persisted.
620            "config_path",
621        ];
622        let extra = yaml
623            .map(|mut m| {
624                for k in known {
625                    m.remove(serde_yaml::Value::String((*k).to_string()));
626                }
627                m
628            })
629            .unwrap_or_default();
630
631        Ok(Config {
632            slug,
633            log_level,
634            log_level_stdout,
635            did_resolver_positive_ttl_secs,
636            did_resolver_negative_ttl_secs,
637            log_file,
638            #[cfg(not(target_arch = "wasm32"))]
639            kubo_rpc_url,
640            #[cfg(not(target_arch = "wasm32"))]
641            kubo_key_alias,
642            secret_bundle,
643            secret_bundle_passphrase,
644            config_path: Some(config_path),
645            extra,
646        })
647    }
648
649    /// The effective log file path: `self.log_file` if set, otherwise the
650    /// XDG default `XDG_DATA_HOME/ma/<slug>.log`.
651    #[cfg(not(target_arch = "wasm32"))]
652    pub fn effective_log_file(&self) -> Result<PathBuf> {
653        if let Some(ref p) = self.log_file {
654            Ok(p.clone())
655        } else {
656            default_log_file_path(&self.slug)
657        }
658    }
659
660    /// The effective secret bundle path: `self.secret_bundle` if set,
661    /// otherwise the XDG default `XDG_CONFIG_HOME/ma/<slug>.bin`.
662    #[cfg(not(target_arch = "wasm32"))]
663    pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
664        if let Some(ref p) = self.secret_bundle {
665            Ok(p.clone())
666        } else {
667            default_secret_bundle_path(&self.slug)
668        }
669    }
670
671    /// Build a gateway-backed DID resolver using config TTL settings.
672    #[cfg(not(target_arch = "wasm32"))]
673    #[must_use]
674    pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
675        crate::ipfs::IpfsGatewayResolver::new(self.kubo_rpc_url.clone()).with_cache_ttls(
676            web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
677            web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
678        )
679    }
680
681    /// Save this config to [`Self::config_path`] as YAML with 0600
682    /// permissions. Returns an error if `config_path` is not set.
683    ///
684    /// Known fields are serialized explicitly; extra fields are merged in
685    /// afterwards so user-defined keys are preserved.
686    #[cfg(not(target_arch = "wasm32"))]
687    pub fn save(&self) -> Result<()> {
688        let path = self
689            .config_path
690            .as_ref()
691            .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
692
693        let yaml_text = self.to_yaml_string()?;
694
695        write_secure(path, yaml_text.as_bytes())
696    }
697
698    /// Generate a complete headless config:
699    ///
700    /// 1. Generate a fresh [`SecretBundle`] with four random 32-byte keys.
701    /// 2. Encrypt the bundle (using `args.secret_bundle_passphrase` or a
702    ///    freshly generated random passphrase).
703    /// 3. Write the encrypted bundle to `XDG_CONFIG_HOME/ma/<slug>.bin`
704    ///    (or the path from `--secret-bundle`) with mode 0600.
705    /// 4. Write the YAML config to `XDG_CONFIG_HOME/ma/<slug>.yaml`
706    ///    (or the path from `--config`) with the passphrase in cleartext and
707    ///    mode 0600.
708    /// 5. Print the paths of both files to stdout.
709    ///
710    /// Returns an error if either file already exists.
711    #[cfg(not(target_arch = "wasm32"))]
712    pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
713        let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
714
715        let config_path = if let Some(ref p) = args.config {
716            p.clone()
717        } else {
718            default_config_path(&slug)?
719        };
720        let bundle_path = if let Some(ref p) = args.secret_bundle {
721            p.clone()
722        } else {
723            default_secret_bundle_path(&slug)?
724        };
725
726        if config_path.exists() {
727            return Err(Error::Config(format!(
728                "config file already exists: {} (remove it first or use --config)",
729                config_path.display()
730            )));
731        }
732        if bundle_path.exists() {
733            return Err(Error::Config(format!(
734                "secret bundle already exists: {} (remove it first or use --secret-bundle)",
735                bundle_path.display()
736            )));
737        }
738
739        // Generate or use provided passphrase.
740        let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
741            p.clone()
742        } else {
743            SecretBundle::generate_passphrase()
744        };
745
746        // Generate and save the bundle.
747        let bundle = SecretBundle::generate();
748        bundle.save(&bundle_path, &passphrase)?;
749
750        // Build and save the config.
751        let config = Config {
752            slug: slug.clone(),
753            log_level: DEFAULT_LOG_LEVEL.to_string(),
754            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
755            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
756            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
757            log_file: None,
758            #[cfg(not(target_arch = "wasm32"))]
759            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
760            #[cfg(not(target_arch = "wasm32"))]
761            kubo_key_alias: slug.clone(),
762            secret_bundle: Some(bundle_path.clone()),
763            secret_bundle_passphrase: Some(passphrase),
764            config_path: Some(config_path.clone()),
765            extra: serde_yaml::Mapping::new(),
766        };
767        config.save()?;
768
769        println!("Config:        {}", config_path.display());
770        println!("Secret bundle: {}", bundle_path.display());
771
772        Ok(())
773    }
774}