Skip to main content

did_hosting_server/
config.rs

1use crate::error::AppError;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5// Re-export shared config types so existing code can still use `crate::config::*`
6pub use did_hosting_common::server::config::{
7    AuthConfig, FeaturesConfig, HostingConfig, LogConfig, LogFormat, SecretsConfig, ServerConfig,
8    StoreConfig, TransportSelection, VtaConfig,
9};
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct AppConfig {
13    #[serde(default)]
14    pub features: FeaturesConfig,
15    pub server_did: Option<String>,
16    pub mediator_did: Option<String>,
17    pub public_url: Option<String>,
18    #[serde(default)]
19    pub server: ServerConfig,
20    #[serde(default)]
21    pub log: LogConfig,
22    #[serde(default)]
23    pub store: StoreConfig,
24    #[serde(default)]
25    pub auth: AuthConfig,
26    /// Multi-domain hosting (bootstrap_domains + unassigned_purge_grace).
27    /// Daemon mode already exposes this via its own DaemonConfig; the
28    /// standalone server gains it here so T28's unassignment handler
29    /// (which lives in `did-hosting-server::messaging`) can read the
30    /// grace duration uniformly.
31    #[serde(default)]
32    pub hosting: HostingConfig,
33    #[serde(default)]
34    pub secrets: SecretsConfig,
35    #[serde(default)]
36    pub limits: LimitsConfig,
37    #[serde(default)]
38    pub stats: StatsConfig,
39    #[serde(default)]
40    pub watchers: Vec<WatcherEndpoint>,
41    /// URL of the control plane for service registration.
42    pub control_url: Option<String>,
43    /// DID of the control plane service (for DIDComm authentication).
44    pub control_did: Option<String>,
45    #[serde(default)]
46    pub vta: VtaConfig,
47    /// How the service's own identity is produced, and how long a superseded
48    /// generation keeps being honoured after a rotation
49    /// (`identity.rotation_grace_period`).
50    #[serde(default)]
51    pub identity: did_hosting_common::server::config::IdentityConfig,
52    #[serde(skip)]
53    pub config_path: PathBuf,
54}
55
56#[derive(Clone, Deserialize, Serialize)]
57pub struct WatcherEndpoint {
58    pub url: String,
59    pub token: Option<String>,
60}
61
62// Manual Debug: `token` is a bearer secret used by webvh-watcher's /sync push
63// auth. Leaking it via a stray debug/trace log of the loaded config would
64// hand any reader live push credentials.
65impl std::fmt::Debug for WatcherEndpoint {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("WatcherEndpoint")
68            .field("url", &self.url)
69            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
70            .finish()
71    }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct LimitsConfig {
76    /// Maximum body size (bytes) for did.jsonl / witness uploads. Default: 100KB.
77    #[serde(default = "default_upload_body_limit")]
78    pub upload_body_limit: usize,
79    /// Default per-account total DID document size (bytes). Default: 1MB.
80    #[serde(default = "default_max_total_size")]
81    pub default_max_total_size: u64,
82    /// Default per-account maximum number of DIDs. Default: 20.
83    #[serde(default = "default_max_did_count")]
84    pub default_max_did_count: u64,
85}
86
87fn default_upload_body_limit() -> usize {
88    102_400
89}
90
91fn default_max_total_size() -> u64 {
92    1_048_576
93}
94
95fn default_max_did_count() -> u64 {
96    20
97}
98
99impl Default for LimitsConfig {
100    fn default() -> Self {
101        Self {
102            upload_body_limit: default_upload_body_limit(),
103            default_max_total_size: default_max_total_size(),
104            default_max_did_count: default_max_did_count(),
105        }
106    }
107}
108
109/// Stats collection and sync configuration.
110#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct StatsConfig {
112    /// How often (seconds) to flush in-memory counters to storage. Default: 5.
113    #[serde(default = "default_stats_flush_interval")]
114    pub flush_interval_secs: u64,
115    /// How often (seconds) to push aggregate stats to the control plane. Default: 1.
116    /// Set to 0 to disable sync.
117    #[serde(default = "default_stats_sync_interval")]
118    pub sync_interval_secs: u64,
119}
120
121fn default_stats_flush_interval() -> u64 {
122    5
123}
124
125fn default_stats_sync_interval() -> u64 {
126    1
127}
128
129impl Default for StatsConfig {
130    fn default() -> Self {
131        Self {
132            flush_interval_secs: default_stats_flush_interval(),
133            sync_interval_secs: default_stats_sync_interval(),
134        }
135    }
136}
137
138impl AppConfig {
139    /// Return the public-facing base URL for this server.
140    pub fn public_base_url(&self) -> String {
141        self.public_url
142            .clone()
143            .unwrap_or_else(|| format!("http://{}:{}", self.server.host, self.server.port))
144    }
145
146    pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
147        let path = config_path
148            .or_else(|| {
149                std::env::var("DID_HOSTING_CONFIG_PATH")
150                    .ok()
151                    .map(PathBuf::from)
152            })
153            .unwrap_or_else(|| PathBuf::from("config.toml"));
154
155        if !path.exists() {
156            return Err(AppError::Config(format!(
157                "configuration file not found: {}",
158                path.display()
159            )));
160        }
161
162        let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
163        let mut config = toml::from_str::<AppConfig>(&contents)
164            .map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
165
166        config.config_path = path.clone();
167
168        // Apply shared env overrides for common config fields
169        did_hosting_common::server::config::apply_env_overrides(
170            "WEBVH",
171            &mut config.features,
172            &mut config.server,
173            &mut config.log,
174            &mut config.store,
175            &mut config.auth,
176            &mut config.secrets,
177        )?;
178
179        // Server identity (did-hosting-server specific env vars)
180        macro_rules! env_opt {
181            ($var:expr, $field:expr) => {
182                if let Ok(v) = std::env::var($var) {
183                    $field = Some(v);
184                }
185            };
186        }
187        macro_rules! env_parse {
188            ($var:expr, $field:expr) => {
189                if let Ok(v) = std::env::var($var) {
190                    $field = v
191                        .parse()
192                        .map_err(|e| AppError::Config(format!("invalid {}: {e}", $var)))?;
193                }
194            };
195        }
196
197        env_opt!("DID_HOSTING_SERVER_DID", config.server_did);
198        env_opt!("DID_HOSTING_MEDIATOR_DID", config.mediator_did);
199        env_opt!("DID_HOSTING_PUBLIC_URL", config.public_url);
200        env_opt!("DID_HOSTING_CONTROL_URL", config.control_url);
201        env_opt!("DID_HOSTING_CONTROL_DID", config.control_did);
202
203        // VTA config
204        env_opt!("DID_HOSTING_VTA_URL", config.vta.url);
205        env_opt!("DID_HOSTING_VTA_DID", config.vta.did);
206        env_opt!("DID_HOSTING_VTA_CONTEXT_ID", config.vta.context_id);
207
208        // Limits
209        env_parse!(
210            "DID_HOSTING_LIMITS_UPLOAD_BODY_LIMIT",
211            config.limits.upload_body_limit
212        );
213        env_parse!(
214            "DID_HOSTING_LIMITS_DEFAULT_MAX_TOTAL_SIZE",
215            config.limits.default_max_total_size
216        );
217        env_parse!(
218            "DID_HOSTING_LIMITS_DEFAULT_MAX_DID_COUNT",
219            config.limits.default_max_did_count
220        );
221
222        // Stats
223        env_parse!(
224            "DID_HOSTING_STATS_FLUSH_INTERVAL_SECS",
225            config.stats.flush_interval_secs
226        );
227        env_parse!(
228            "DID_HOSTING_STATS_SYNC_INTERVAL_SECS",
229            config.stats.sync_interval_secs
230        );
231
232        // Validate configuration
233        config.auth.validate()?;
234        if let Some(ref did) = config.server_did
235            && !did.starts_with("did:")
236        {
237            return Err(AppError::Config(format!(
238                "server_did must start with 'did:': {did}"
239            )));
240        }
241        if let Some(ref url) = config.public_url
242            && !url.starts_with("http://")
243            && !url.starts_with("https://")
244        {
245            return Err(AppError::Config(format!(
246                "public_url must start with http:// or https://: {url}"
247            )));
248        }
249
250        // Normalize: strip trailing slashes from URLs
251        if let Some(ref mut url) = config.public_url {
252            let trimmed = url.trim_end_matches('/').to_string();
253            *url = trimmed;
254        }
255        if let Some(ref mut url) = config.control_url {
256            let trimmed = url.trim_end_matches('/').to_string();
257            *url = trimmed;
258        }
259
260        Ok(config)
261    }
262}