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        // NOTE: `slug` is intentionally omitted — it selects which config file
399        // to open, so storing it inside that file is a catch-22.
400        // It is read only from CLI (--slug) or env (MA_SLUG), never from YAML.
401        set(
402            "log_level",
403            serde_yaml::Value::String(self.log_level.clone()),
404        );
405        set(
406            "log_level_stdout",
407            serde_yaml::Value::String(self.log_level_stdout.clone()),
408        );
409        set(
410            "did_resolver_positive_ttl_secs",
411            serde_yaml::Value::Number(serde_yaml::Number::from(
412                self.did_resolver_positive_ttl_secs,
413            )),
414        );
415        set(
416            "did_resolver_negative_ttl_secs",
417            serde_yaml::Value::Number(serde_yaml::Number::from(
418                self.did_resolver_negative_ttl_secs,
419            )),
420        );
421        #[cfg(not(target_arch = "wasm32"))]
422        set(
423            "kubo_rpc_url",
424            serde_yaml::Value::String(self.kubo_rpc_url.clone()),
425        );
426        #[cfg(not(target_arch = "wasm32"))]
427        set(
428            "kubo_key_alias",
429            serde_yaml::Value::String(self.kubo_key_alias.clone()),
430        );
431
432        if let Some(ref p) = self.log_file {
433            set(
434                "log_file",
435                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
436            );
437        }
438        if let Some(ref p) = self.secret_bundle {
439            set(
440                "secret_bundle",
441                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
442            );
443        }
444        if let Some(ref pw) = self.secret_bundle_passphrase {
445            set(
446                "secret_bundle_passphrase",
447                serde_yaml::Value::String(pw.clone()),
448            );
449        }
450
451        serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
452            .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
453    }
454
455    /// Serialize config to YAML text while excluding secret passphrase fields.
456    ///
457    /// Useful for browser storage where passphrases should be provided by
458    /// runtime user input instead of persisted state.
459    pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
460        let mut copy = self.clone();
461        copy.secret_bundle_passphrase = None;
462        copy.to_yaml_string()
463    }
464
465    #[cfg(not(target_arch = "wasm32"))]
466    /// Build a `Config` by merging CLI arguments, environment variables, a
467    /// YAML config file, and built-in defaults.
468    ///
469    /// # Required compile-time constant
470    ///
471    /// Callers **MUST** pass a compile-time constant `MA_DEFAULT_SLUG: &'static str`.
472    /// This determines BOTH the default slug for file naming AND the fixed
473    /// env-var prefix `MA_<MA_DEFAULT_SLUG>_*`. The prefix cannot be changed
474    /// at runtime; only file naming may be overridden via `--slug`.
475    ///
476    /// ```
477    /// # #[cfg(all(feature = "config", not(target_arch = "wasm32")))]
478    /// # {
479    /// use ma_core::config::{Config, MaArgs};
480    /// let args = MaArgs::default();
481    /// let config = Config::from_args(&args, "doctest")?;
482    /// assert_eq!(config.slug, "doctest");
483    /// # }
484    /// # Ok::<(), ma_core::Error>(())
485    /// ```
486    ///
487    /// # Priority
488    ///
489    /// For each field the resolution order is:
490    /// 1. Explicit CLI argument
491    /// 2. `MA_<MA_DEFAULT_SLUG>_FIELD` environment variable
492    /// 3. `MA_FIELD` environment variable (static fallback)
493    /// 4. Value from the YAML config file
494    /// 5. Built-in default
495    #[allow(clippy::too_many_lines)]
496    pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
497        // The env-var prefix is determined by the compile-time constant.
498        // e.g. default_slug = "panteia"  →  prefix = "PANTEIA"
499        let prefix = default_slug.to_uppercase().replace('-', "_");
500
501        // Slug: CLI/env via clap (MA_SLUG) → compile-time default.
502        let slug = args
503            .slug
504            .clone()
505            .unwrap_or_else(|| default_slug.to_string());
506
507        // Config file path: explicit → slug-based XDG default.
508        let config_path = if let Some(ref p) = args.config {
509            p.clone()
510        } else {
511            default_config_path(&slug)?
512        };
513
514        // Load YAML if the file exists.
515        let yaml = if config_path.exists() {
516            check_permissions(&config_path);
517            Some(load_yaml_mapping(&config_path)?)
518        } else {
519            None
520        };
521
522        // Helper: resolve a string field through the priority chain.
523        // NOTE: closures borrow `yaml` and `prefix` immutably; NLL ensures
524        // the borrows end before we move `yaml` below.
525        let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
526            cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
527                .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
528                .or_else(|| {
529                    yaml.as_ref()
530                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
531                })
532                .unwrap_or_else(|| default.to_string())
533        };
534
535        let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
536            cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
537                .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
538                .or_else(|| {
539                    yaml.as_ref()
540                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
541                })
542        };
543
544        let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
545            cli.or_else(|| {
546                std::env::var(format!("MA_{prefix}_{env_key}"))
547                    .ok()
548                    .map(PathBuf::from)
549            })
550            .or_else(|| {
551                std::env::var(format!("MA_{env_key}"))
552                    .ok()
553                    .map(PathBuf::from)
554            })
555            .or_else(|| {
556                yaml.as_ref()
557                    .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
558            })
559        };
560
561        let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
562            cli.or_else(|| {
563                std::env::var(format!("MA_{prefix}_{env_key}"))
564                    .ok()
565                    .and_then(|v| v.parse::<u64>().ok())
566            })
567            .or_else(|| {
568                std::env::var(format!("MA_{env_key}"))
569                    .ok()
570                    .and_then(|v| v.parse::<u64>().ok())
571            })
572            .or_else(|| {
573                yaml.as_ref()
574                    .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
575            })
576            .unwrap_or(default)
577        };
578
579        let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
580        let log_level_stdout = resolve_str(
581            args.log_level_stdout.clone(),
582            "LOG_LEVEL_STDOUT",
583            DEFAULT_LOG_LEVEL_STDOUT,
584        );
585        let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
586        let did_resolver_positive_ttl_secs = resolve_u64(
587            args.did_resolver_positive_ttl_secs,
588            "DID_RESOLVER_POSITIVE_TTL_SECS",
589            DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
590        );
591        let did_resolver_negative_ttl_secs = resolve_u64(
592            args.did_resolver_negative_ttl_secs,
593            "DID_RESOLVER_NEGATIVE_TTL_SECS",
594            DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
595        );
596        let kubo_rpc_url = resolve_str(
597            args.kubo_rpc_url.clone(),
598            "KUBO_RPC_URL",
599            DEFAULT_KUBO_RPC_URL,
600        );
601        let kubo_key_alias =
602            resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
603        let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
604        let secret_bundle_passphrase = resolve_opt_str(
605            args.secret_bundle_passphrase.clone(),
606            "SECRET_BUNDLE_PASSPHRASE",
607        );
608
609        // Extra: all YAML keys that are not part of the core schema.
610        let known: &[&str] = &[
611            "slug",
612            "log_level",
613            "log_level_stdout",
614            "log_file",
615            "did_resolver_positive_ttl_secs",
616            "did_resolver_negative_ttl_secs",
617            "kubo_rpc_url",
618            "kubo_key_alias",
619            "secret_bundle",
620            "secret_bundle_passphrase",
621            // Legacy key; ignored and never persisted.
622            "config_path",
623        ];
624        let extra = yaml
625            .map(|mut m| {
626                for k in known {
627                    m.remove(serde_yaml::Value::String((*k).to_string()));
628                }
629                m
630            })
631            .unwrap_or_default();
632
633        Ok(Config {
634            slug,
635            log_level,
636            log_level_stdout,
637            did_resolver_positive_ttl_secs,
638            did_resolver_negative_ttl_secs,
639            log_file,
640            #[cfg(not(target_arch = "wasm32"))]
641            kubo_rpc_url,
642            #[cfg(not(target_arch = "wasm32"))]
643            kubo_key_alias,
644            secret_bundle,
645            secret_bundle_passphrase,
646            config_path: Some(config_path),
647            extra,
648        })
649    }
650
651    /// The effective log file path: `self.log_file` if set, otherwise the
652    /// XDG default `XDG_DATA_HOME/ma/<slug>.log`.
653    #[cfg(not(target_arch = "wasm32"))]
654    pub fn effective_log_file(&self) -> Result<PathBuf> {
655        if let Some(ref p) = self.log_file {
656            Ok(p.clone())
657        } else {
658            default_log_file_path(&self.slug)
659        }
660    }
661
662    /// The effective secret bundle path: `self.secret_bundle` if set,
663    /// otherwise the XDG default `XDG_CONFIG_HOME/ma/<slug>.bin`.
664    #[cfg(not(target_arch = "wasm32"))]
665    pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
666        if let Some(ref p) = self.secret_bundle {
667            Ok(p.clone())
668        } else {
669            default_secret_bundle_path(&self.slug)
670        }
671    }
672
673    /// Build a gateway-backed DID resolver using config TTL settings.
674    ///
675    /// Uses the built-in gateway list (localhost:8080 + public fallbacks).
676    /// Works on both native and WASM targets.
677    #[must_use]
678    pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
679        crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
680            web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
681            web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
682        )
683    }
684
685    /// Save this config to [`Self::config_path`] as YAML with 0600
686    /// permissions. Returns an error if `config_path` is not set.
687    ///
688    /// Known fields are serialized explicitly; extra fields are merged in
689    /// afterwards so user-defined keys are preserved.
690    #[cfg(not(target_arch = "wasm32"))]
691    pub fn save(&self) -> Result<()> {
692        let path = self
693            .config_path
694            .as_ref()
695            .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
696
697        let yaml_text = self.to_yaml_string()?;
698
699        write_secure(path, yaml_text.as_bytes())
700    }
701
702    /// Generate a complete headless config:
703    ///
704    /// 1. Generate a fresh [`SecretBundle`] with four random 32-byte keys.
705    /// 2. Encrypt the bundle (using `args.secret_bundle_passphrase` or a
706    ///    freshly generated random passphrase).
707    /// 3. Write the encrypted bundle to `XDG_CONFIG_HOME/ma/<slug>.bin`
708    ///    (or the path from `--secret-bundle`) with mode 0600.
709    /// 4. Write the YAML config to `XDG_CONFIG_HOME/ma/<slug>.yaml`
710    ///    (or the path from `--config`) with the passphrase in cleartext and
711    ///    mode 0600.
712    /// 5. Print the paths of both files to stdout.
713    ///
714    /// Returns an error if either file already exists.
715    #[cfg(not(target_arch = "wasm32"))]
716    pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
717        let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
718
719        let config_path = if let Some(ref p) = args.config {
720            p.clone()
721        } else {
722            default_config_path(&slug)?
723        };
724        let bundle_path = if let Some(ref p) = args.secret_bundle {
725            p.clone()
726        } else {
727            default_secret_bundle_path(&slug)?
728        };
729
730        if config_path.exists() {
731            return Err(Error::Config(format!(
732                "config file already exists: {} (remove it first or use --config)",
733                config_path.display()
734            )));
735        }
736        if bundle_path.exists() {
737            return Err(Error::Config(format!(
738                "secret bundle already exists: {} (remove it first or use --secret-bundle)",
739                bundle_path.display()
740            )));
741        }
742
743        // Generate or use provided passphrase.
744        let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
745            p.clone()
746        } else {
747            SecretBundle::generate_passphrase()
748        };
749
750        // Generate and save the bundle.
751        let bundle = SecretBundle::generate();
752        bundle.save(&bundle_path, &passphrase)?;
753
754        // Build and save the config.
755        let config = Config {
756            slug: slug.clone(),
757            log_level: DEFAULT_LOG_LEVEL.to_string(),
758            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
759            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
760            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
761            log_file: None,
762            #[cfg(not(target_arch = "wasm32"))]
763            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
764            #[cfg(not(target_arch = "wasm32"))]
765            kubo_key_alias: slug.clone(),
766            secret_bundle: Some(bundle_path.clone()),
767            secret_bundle_passphrase: Some(passphrase),
768            config_path: Some(config_path.clone()),
769            extra: serde_yaml::Mapping::new(),
770        };
771        config.save()?;
772
773        println!("Config:        {}", config_path.display());
774        println!("Secret bundle: {}", bundle_path.display());
775
776        Ok(())
777    }
778}