An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4use zeroize::Zeroizing;
5
6/// A wrapper that suppresses [`Debug`] output for sensitive values, printing `***` instead.
7///
8/// `T` is `pub` to allow deliberate access via `.0` at call sites. This is an explicit choice:
9/// any read of the raw value is visible in source, making accidental logging harder to miss in
10/// code review.
11#[derive(Clone)]
12pub struct Sensitive<T>(pub T);
13
14impl<T> std::fmt::Debug for Sensitive<T> {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 f.write_str("***")
17 }
18}
19
20/// Validated, fully-resolved relay configuration.
21#[derive(Debug, Clone)]
22pub struct Config {
23 pub bind_address: String,
24 pub port: u16,
25 pub data_dir: PathBuf,
26 pub database_url: String,
27 pub public_url: String,
28 pub server_did: Option<String>,
29 pub available_user_domains: Vec<String>,
30 pub invite_code_required: bool,
31 pub links: ServerLinksConfig,
32 pub contact: ContactConfig,
33 pub blobs: BlobsConfig,
34 pub oauth: OAuthConfig,
35 pub iroh: IrohConfig,
36 pub telemetry: TelemetryConfig,
37 // Operator authentication for management endpoints (e.g., POST /v1/relay/keys).
38 pub admin_token: Option<String>,
39 // AES-256-GCM master key for encrypting signing key private keys at rest.
40 pub signing_key_master_key: Option<Sensitive<Zeroizing<[u8; 32]>>>,
41 // URL of the PLC directory service (default: https://plc.directory)
42 pub plc_directory_url: String,
43}
44
45/// Optional privacy/ToS links surfaced by `com.atproto.server.describeServer`.
46#[derive(Debug, Clone, Deserialize, Default)]
47pub struct ServerLinksConfig {
48 pub privacy_policy: Option<String>,
49 pub terms_of_service: Option<String>,
50}
51
52/// Optional admin contact surfaced by `com.atproto.server.describeServer`.
53#[derive(Debug, Clone, Deserialize, Default)]
54pub struct ContactConfig {
55 pub email: Option<String>,
56}
57
58/// Stub for future blob storage configuration.
59#[derive(Debug, Clone, Deserialize, Default)]
60pub struct BlobsConfig {}
61
62/// Stub for future OAuth configuration.
63#[derive(Debug, Clone, Deserialize, Default)]
64pub struct OAuthConfig {}
65
66/// Iroh networking configuration.
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct IrohConfig {
69 /// Iroh node endpoint for NAT traversal. `None` when not configured.
70 pub endpoint: Option<String>,
71}
72
73/// OpenTelemetry telemetry configuration.
74#[derive(Debug, Clone)]
75pub struct TelemetryConfig {
76 /// Whether to export traces via OTLP. Off by default — zero overhead when disabled.
77 pub enabled: bool,
78 /// OTLP gRPC endpoint for the trace exporter.
79 pub otlp_endpoint: String,
80 /// `service.name` resource attribute reported to the trace backend.
81 pub service_name: String,
82}
83
84impl Default for TelemetryConfig {
85 fn default() -> Self {
86 Self {
87 enabled: false,
88 otlp_endpoint: "http://localhost:4317".to_string(),
89 service_name: "ezpds-relay".to_string(),
90 }
91 }
92}
93
94#[derive(Debug, Deserialize, Default)]
95pub(crate) struct RawTelemetryConfig {
96 pub(crate) enabled: Option<bool>,
97 pub(crate) otlp_endpoint: Option<String>,
98 pub(crate) service_name: Option<String>,
99}
100
101/// Raw TOML-deserialized config with all fields optional to support env-var overlays.
102#[derive(Debug, Deserialize, Default)]
103pub(crate) struct RawConfig {
104 pub(crate) bind_address: Option<String>,
105 pub(crate) port: Option<u16>,
106 pub(crate) data_dir: Option<String>,
107 pub(crate) database_url: Option<String>,
108 pub(crate) public_url: Option<String>,
109 pub(crate) server_did: Option<String>,
110 pub(crate) available_user_domains: Option<Vec<String>>,
111 pub(crate) invite_code_required: Option<bool>,
112 #[serde(default)]
113 pub(crate) links: ServerLinksConfig,
114 #[serde(default)]
115 pub(crate) contact: ContactConfig,
116 #[serde(default)]
117 pub(crate) blobs: BlobsConfig,
118 #[serde(default)]
119 pub(crate) oauth: OAuthConfig,
120 #[serde(default)]
121 pub(crate) iroh: IrohConfig,
122 #[serde(default)]
123 pub(crate) telemetry: RawTelemetryConfig,
124 pub(crate) admin_token: Option<String>,
125 pub(crate) plc_directory_url: Option<String>,
126 #[serde(skip)]
127 pub(crate) signing_key_master_key: Option<[u8; 32]>,
128 /// Sentinel field — only present to detect misconfiguration.
129 /// signing_key_master_key must be set via env var EZPDS_SIGNING_KEY_MASTER_KEY, not TOML.
130 #[serde(rename = "signing_key_master_key")]
131 pub(crate) signing_key_master_key_toml_sentinel: Option<String>,
132}
133
134#[derive(Debug, thiserror::Error)]
135pub enum ConfigError {
136 #[error("failed to read config file {path}: {source}")]
137 Io {
138 path: PathBuf,
139 #[source]
140 source: std::io::Error,
141 },
142 #[error("failed to parse config file: {0}")]
143 Parse(#[from] toml::de::Error),
144 #[error("invalid configuration: missing required field '{field}'")]
145 MissingField { field: &'static str },
146 #[error("invalid configuration: {0}")]
147 Invalid(String),
148}
149
150/// Parse a 64-character hex string into a 32-byte array.
151/// Returns a human-readable error string on failure.
152fn parse_hex_32(var_name: &str, value: &str) -> Result<[u8; 32], ConfigError> {
153 if value.len() != 64 {
154 return Err(ConfigError::Invalid(format!(
155 "{var_name} must be exactly 64 hex characters (32 bytes), got {} characters",
156 value.len()
157 )));
158 }
159 let mut bytes = [0u8; 32];
160 for (i, pair) in value.as_bytes().chunks(2).enumerate() {
161 let hi = hex_nibble(var_name, pair[0])?;
162 let lo = hex_nibble(var_name, pair[1])?;
163 bytes[i] = (hi << 4) | lo;
164 }
165 Ok(bytes)
166}
167
168fn hex_nibble(var_name: &str, b: u8) -> Result<u8, ConfigError> {
169 match b {
170 b'0'..=b'9' => Ok(b - b'0'),
171 b'a'..=b'f' => Ok(b - b'a' + 10),
172 b'A'..=b'F' => Ok(b - b'A' + 10),
173 _ => Err(ConfigError::Invalid(format!(
174 "{var_name} contains invalid hex character: {:?}",
175 char::from(b)
176 ))),
177 }
178}
179
180/// Apply `EZPDS_*` and selected OTel standard environment variable overrides to a [`RawConfig`],
181/// returning the updated config.
182///
183/// Also reads `OTEL_SERVICE_NAME` (without the `EZPDS_` prefix) as a standard OpenTelemetry
184/// convention for overriding the telemetry service name.
185///
186/// Receives the environment as a map so this function stays isolated from I/O (no `std::env`
187/// access). Takes `raw` by value and returns it so callers can chain calls without mutation.
188pub(crate) fn apply_env_overrides(
189 mut raw: RawConfig,
190 env: &HashMap<String, String>,
191) -> Result<RawConfig, ConfigError> {
192 if let Some(v) = env.get("EZPDS_BIND_ADDRESS") {
193 raw.bind_address = Some(v.clone());
194 }
195 if let Some(v) = env.get("EZPDS_PORT") {
196 raw.port = Some(v.parse::<u16>().map_err(|e| {
197 ConfigError::Invalid(format!("EZPDS_PORT is not a valid port number: '{v}': {e}"))
198 })?);
199 }
200 if let Some(v) = env.get("EZPDS_DATA_DIR") {
201 raw.data_dir = Some(v.clone());
202 }
203 if let Some(v) = env.get("EZPDS_DATABASE_URL") {
204 raw.database_url = Some(v.clone());
205 }
206 if let Some(v) = env.get("EZPDS_PUBLIC_URL") {
207 raw.public_url = Some(v.clone());
208 }
209 if let Some(v) = env.get("EZPDS_SERVER_DID") {
210 raw.server_did = Some(v.clone());
211 }
212 if let Some(v) = env.get("EZPDS_INVITE_CODE_REQUIRED") {
213 raw.invite_code_required = Some(v.parse::<bool>().map_err(|e| {
214 ConfigError::Invalid(format!(
215 "EZPDS_INVITE_CODE_REQUIRED is not a valid boolean: '{v}': {e}"
216 ))
217 })?);
218 }
219 if let Some(v) = env.get("EZPDS_AVAILABLE_USER_DOMAINS") {
220 raw.available_user_domains = Some(
221 v.split(',')
222 .map(str::trim)
223 .filter(|s| !s.is_empty())
224 .map(str::to_string)
225 .collect(),
226 );
227 }
228 if let Some(v) = env.get("EZPDS_TELEMETRY_ENABLED") {
229 raw.telemetry.enabled = Some(v.parse::<bool>().map_err(|e| {
230 ConfigError::Invalid(format!(
231 "EZPDS_TELEMETRY_ENABLED is not a valid boolean: '{v}': {e}"
232 ))
233 })?);
234 }
235 if let Some(v) = env.get("EZPDS_OTLP_ENDPOINT") {
236 raw.telemetry.otlp_endpoint = Some(v.clone());
237 }
238 if let Some(v) = env.get("OTEL_SERVICE_NAME") {
239 raw.telemetry.service_name = Some(v.clone());
240 }
241 if let Some(v) = env.get("EZPDS_IROH_ENDPOINT") {
242 raw.iroh.endpoint = Some(v.clone());
243 }
244 if let Some(v) = env.get("EZPDS_ADMIN_TOKEN") {
245 raw.admin_token = Some(v.clone());
246 }
247 if let Some(v) = env.get("EZPDS_PLC_DIRECTORY_URL") {
248 raw.plc_directory_url = Some(v.clone());
249 }
250 if let Some(v) = env.get("EZPDS_SIGNING_KEY_MASTER_KEY") {
251 raw.signing_key_master_key = Some(parse_hex_32("EZPDS_SIGNING_KEY_MASTER_KEY", v)?);
252 }
253 Ok(raw)
254}
255
256/// Validate a [`RawConfig`] and build a [`Config`], applying defaults for optional fields.
257///
258/// Required fields: `data_dir`, `public_url`, `available_user_domains` (non-empty).
259/// Defaults: `bind_address = "0.0.0.0"`, `port = 8080`, `invite_code_required = true`,
260/// `database_url = "{data_dir}/relay.db"` (derived; fails if `data_dir` is non-UTF-8),
261/// `telemetry.enabled = false`, `telemetry.otlp_endpoint = "http://localhost:4317"`,
262/// `telemetry.service_name = "ezpds-relay"`.
263/// When provided, `telemetry.otlp_endpoint` must be non-empty and start with `http://` or
264/// `https://`.
265pub(crate) fn validate_and_build(raw: RawConfig) -> Result<Config, ConfigError> {
266 // Reject signing_key_master_key if it appears in TOML (must be env var only).
267 if raw.signing_key_master_key_toml_sentinel.is_some() {
268 return Err(ConfigError::Invalid(
269 "signing_key_master_key must be set via env var EZPDS_SIGNING_KEY_MASTER_KEY, not relay.toml (security-sensitive field)".to_string()
270 ));
271 }
272
273 let bind_address = raw.bind_address.unwrap_or_else(|| "0.0.0.0".to_string());
274 let port = raw.port.unwrap_or(8080);
275 let data_dir: PathBuf = raw
276 .data_dir
277 .ok_or(ConfigError::MissingField { field: "data_dir" })?
278 .into();
279 let database_url = match raw.database_url {
280 Some(url) => url,
281 None => data_dir
282 .join("relay.db")
283 .to_str()
284 .ok_or_else(|| {
285 ConfigError::Invalid(
286 "data_dir contains non-UTF-8 characters, cannot derive database_url"
287 .to_string(),
288 )
289 })?
290 .to_owned(),
291 };
292 let public_url = raw.public_url.ok_or(ConfigError::MissingField {
293 field: "public_url",
294 })?;
295 if !public_url.starts_with("https://") {
296 return Err(ConfigError::Invalid(format!(
297 "public_url must start with https:// (RFC 8414 requires HTTPS for the OAuth issuer), got: {public_url:?}"
298 )));
299 }
300 let available_user_domains = raw
301 .available_user_domains
302 .ok_or(ConfigError::MissingField {
303 field: "available_user_domains",
304 })?;
305 if available_user_domains.is_empty() {
306 return Err(ConfigError::Invalid(
307 "available_user_domains must contain at least one domain".to_string(),
308 ));
309 }
310 let invite_code_required = raw.invite_code_required.unwrap_or(true);
311 let plc_directory_url = raw
312 .plc_directory_url
313 .unwrap_or_else(|| "https://plc.directory".to_string());
314
315 let telemetry_defaults = TelemetryConfig::default();
316 let otlp_endpoint = raw
317 .telemetry
318 .otlp_endpoint
319 .unwrap_or(telemetry_defaults.otlp_endpoint);
320 if otlp_endpoint.is_empty() {
321 return Err(ConfigError::Invalid(
322 "telemetry.otlp_endpoint must not be empty".to_string(),
323 ));
324 }
325 if !otlp_endpoint.starts_with("http://") && !otlp_endpoint.starts_with("https://") {
326 return Err(ConfigError::Invalid(format!(
327 "telemetry.otlp_endpoint must start with http:// or https://, got: {otlp_endpoint:?}"
328 )));
329 }
330 let telemetry = TelemetryConfig {
331 enabled: raw.telemetry.enabled.unwrap_or(telemetry_defaults.enabled),
332 otlp_endpoint,
333 service_name: raw
334 .telemetry
335 .service_name
336 .unwrap_or(telemetry_defaults.service_name),
337 };
338
339 if raw.iroh.endpoint.as_deref() == Some("") {
340 return Err(ConfigError::Invalid(
341 "iroh.endpoint must not be empty".to_string(),
342 ));
343 }
344
345 Ok(Config {
346 bind_address,
347 port,
348 data_dir,
349 database_url,
350 public_url,
351 server_did: raw.server_did,
352 available_user_domains,
353 invite_code_required,
354 links: raw.links,
355 contact: raw.contact,
356 blobs: raw.blobs,
357 oauth: raw.oauth,
358 iroh: raw.iroh,
359 telemetry,
360 admin_token: raw.admin_token,
361 signing_key_master_key: raw
362 .signing_key_master_key
363 .map(|k| Sensitive(Zeroizing::new(k))),
364 plc_directory_url,
365 })
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn minimal_raw() -> RawConfig {
373 RawConfig {
374 data_dir: Some("/var/pds".to_string()),
375 public_url: Some("https://pds.example.com".to_string()),
376 available_user_domains: Some(vec!["example.com".to_string()]),
377 ..Default::default()
378 }
379 }
380
381 #[test]
382 fn parses_minimal_toml() {
383 let toml = r#"
384 data_dir = "/var/pds"
385 public_url = "https://pds.example.com"
386 available_user_domains = ["example.com"]
387 "#;
388 let raw: RawConfig = toml::from_str(toml).unwrap();
389 let config = validate_and_build(raw).unwrap();
390
391 assert_eq!(config.bind_address, "0.0.0.0");
392 assert_eq!(config.port, 8080);
393 assert_eq!(config.data_dir, PathBuf::from("/var/pds"));
394 assert_eq!(config.database_url, "/var/pds/relay.db");
395 assert_eq!(config.public_url, "https://pds.example.com");
396 }
397
398 #[test]
399 fn parses_full_toml() {
400 let toml = r#"
401 bind_address = "127.0.0.1"
402 port = 3000
403 data_dir = "/data"
404 database_url = "sqlite:///data/custom.db"
405 public_url = "https://pds.example.com"
406 available_user_domains = ["example.com"]
407 "#;
408 let raw: RawConfig = toml::from_str(toml).unwrap();
409 let config = validate_and_build(raw).unwrap();
410
411 assert_eq!(config.bind_address, "127.0.0.1");
412 assert_eq!(config.port, 3000);
413 assert_eq!(config.data_dir, PathBuf::from("/data"));
414 assert_eq!(config.database_url, "sqlite:///data/custom.db");
415 }
416
417 #[test]
418 fn parses_stub_sections() {
419 let toml = r#"
420 data_dir = "/var/pds"
421 public_url = "https://pds.example.com"
422 available_user_domains = ["example.com"]
423
424 [blobs]
425
426 [oauth]
427
428 [iroh]
429 "#;
430 let raw: RawConfig = toml::from_str(toml).unwrap();
431 let config = validate_and_build(raw).unwrap();
432
433 assert_eq!(config.public_url, "https://pds.example.com");
434 }
435
436 #[test]
437 fn database_url_defaults_to_data_dir() {
438 let config = validate_and_build(minimal_raw()).unwrap();
439 assert_eq!(config.database_url, "/var/pds/relay.db");
440 }
441
442 #[test]
443 fn env_override_port() {
444 let env = HashMap::from([("EZPDS_PORT".to_string(), "9090".to_string())]);
445 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
446 let config = validate_and_build(raw).unwrap();
447
448 assert_eq!(config.port, 9090);
449 }
450
451 #[test]
452 fn env_override_wins_over_toml_value() {
453 // env always takes precedence over explicit TOML values
454 let toml = r#"
455 data_dir = "/var/pds"
456 port = 3000
457 public_url = "https://pds.example.com"
458 available_user_domains = ["example.com"]
459 "#;
460 let raw: RawConfig = toml::from_str(toml).unwrap();
461 let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]);
462 let raw = apply_env_overrides(raw, &env).unwrap();
463 let config = validate_and_build(raw).unwrap();
464
465 assert_eq!(config.port, 9999);
466 }
467
468 #[test]
469 fn env_override_all_fields() {
470 let env = HashMap::from([
471 ("EZPDS_BIND_ADDRESS".to_string(), "127.0.0.1".to_string()),
472 ("EZPDS_PORT".to_string(), "4000".to_string()),
473 ("EZPDS_DATA_DIR".to_string(), "/tmp/pds".to_string()),
474 (
475 "EZPDS_DATABASE_URL".to_string(),
476 "sqlite:///tmp/relay.db".to_string(),
477 ),
478 (
479 "EZPDS_PUBLIC_URL".to_string(),
480 "https://pds.test".to_string(),
481 ),
482 (
483 "EZPDS_AVAILABLE_USER_DOMAINS".to_string(),
484 "pds.test".to_string(),
485 ),
486 ]);
487 let raw = apply_env_overrides(RawConfig::default(), &env).unwrap();
488 let config = validate_and_build(raw).unwrap();
489
490 assert_eq!(config.bind_address, "127.0.0.1");
491 assert_eq!(config.port, 4000);
492 assert_eq!(config.data_dir, PathBuf::from("/tmp/pds"));
493 assert_eq!(config.database_url, "sqlite:///tmp/relay.db");
494 assert_eq!(config.public_url, "https://pds.test");
495 }
496
497 #[test]
498 fn env_override_invalid_port_returns_error() {
499 let env = HashMap::from([("EZPDS_PORT".to_string(), "not_a_port".to_string())]);
500 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err();
501
502 assert!(matches!(err, ConfigError::Invalid(_)));
503 assert!(err.to_string().contains("EZPDS_PORT"));
504 assert!(err.to_string().contains("not_a_port"));
505 }
506
507 #[test]
508 fn missing_data_dir_returns_error() {
509 let raw = RawConfig {
510 public_url: Some("https://pds.example.com".to_string()),
511 ..Default::default()
512 };
513 let err = validate_and_build(raw).unwrap_err();
514
515 assert!(matches!(
516 err,
517 ConfigError::MissingField { field: "data_dir" }
518 ));
519 }
520
521 #[test]
522 fn missing_public_url_returns_error() {
523 let raw = RawConfig {
524 data_dir: Some("/var/pds".to_string()),
525 ..Default::default()
526 };
527 let err = validate_and_build(raw).unwrap_err();
528
529 assert!(matches!(
530 err,
531 ConfigError::MissingField {
532 field: "public_url"
533 }
534 ));
535 }
536
537 // --- describeServer config fields ---
538
539 #[test]
540 fn parses_describe_server_fields_from_toml() {
541 let toml = r#"
542 data_dir = "/var/pds"
543 public_url = "https://pds.example.com"
544 server_did = "did:plc:abc123"
545 available_user_domains = ["pds.example.com", "alt.example.com"]
546 invite_code_required = false
547
548 [links]
549 privacy_policy = "https://example.com/privacy"
550 terms_of_service = "https://example.com/tos"
551
552 [contact]
553 email = "admin@example.com"
554 "#;
555 let raw: RawConfig = toml::from_str(toml).unwrap();
556 let config = validate_and_build(raw).unwrap();
557
558 assert_eq!(config.server_did.as_deref(), Some("did:plc:abc123"));
559 assert_eq!(
560 config.available_user_domains,
561 vec!["pds.example.com", "alt.example.com"]
562 );
563 assert!(!config.invite_code_required);
564 assert_eq!(
565 config.links.privacy_policy.as_deref(),
566 Some("https://example.com/privacy")
567 );
568 assert_eq!(
569 config.links.terms_of_service.as_deref(),
570 Some("https://example.com/tos")
571 );
572 assert_eq!(config.contact.email.as_deref(), Some("admin@example.com"));
573 }
574
575 #[test]
576 fn public_url_without_https_scheme_returns_error() {
577 for bad_url in &[
578 "pds.example.com",
579 "http://pds.example.com",
580 "ftp://pds.example.com",
581 "",
582 ] {
583 let raw = RawConfig {
584 data_dir: Some("/var/pds".to_string()),
585 public_url: Some(bad_url.to_string()),
586 available_user_domains: Some(vec!["example.com".to_string()]),
587 ..Default::default()
588 };
589 let err = validate_and_build(raw).unwrap_err();
590 assert!(
591 matches!(err, ConfigError::Invalid(_)),
592 "expected Invalid error for public_url={bad_url:?}, got: {err}"
593 );
594 assert!(
595 err.to_string().contains("https://"),
596 "error message should mention https:// for public_url={bad_url:?}"
597 );
598 }
599 }
600
601 #[test]
602 fn available_user_domains_missing_returns_error() {
603 let raw = RawConfig {
604 data_dir: Some("/var/pds".to_string()),
605 public_url: Some("https://pds.example.com".to_string()),
606 ..Default::default()
607 };
608 let err = validate_and_build(raw).unwrap_err();
609
610 assert!(matches!(
611 err,
612 ConfigError::MissingField {
613 field: "available_user_domains"
614 }
615 ));
616 }
617
618 #[test]
619 fn available_user_domains_empty_returns_invalid_error() {
620 let raw = RawConfig {
621 data_dir: Some("/var/pds".to_string()),
622 public_url: Some("https://pds.example.com".to_string()),
623 available_user_domains: Some(vec![]),
624 ..Default::default()
625 };
626 let err = validate_and_build(raw).unwrap_err();
627
628 assert!(matches!(err, ConfigError::Invalid(_)));
629 assert!(err
630 .to_string()
631 .contains("available_user_domains must contain at least one domain"));
632 }
633
634 #[test]
635 fn invite_code_required_defaults_to_true() {
636 let config = validate_and_build(minimal_raw()).unwrap();
637 assert!(config.invite_code_required);
638 }
639
640 #[test]
641 fn server_did_is_optional() {
642 let config = validate_and_build(minimal_raw()).unwrap();
643 assert!(config.server_did.is_none());
644 }
645
646 #[test]
647 fn links_section_optional() {
648 let config = validate_and_build(minimal_raw()).unwrap();
649 assert!(config.links.privacy_policy.is_none());
650 assert!(config.links.terms_of_service.is_none());
651 }
652
653 #[test]
654 fn contact_section_optional() {
655 let config = validate_and_build(minimal_raw()).unwrap();
656 assert!(config.contact.email.is_none());
657 }
658
659 #[test]
660 fn env_override_server_did() {
661 let env = HashMap::from([("EZPDS_SERVER_DID".to_string(), "did:plc:xyz".to_string())]);
662 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
663 let config = validate_and_build(raw).unwrap();
664
665 assert_eq!(config.server_did.as_deref(), Some("did:plc:xyz"));
666 }
667
668 #[test]
669 fn env_override_invite_code_required_false() {
670 let env = HashMap::from([(
671 "EZPDS_INVITE_CODE_REQUIRED".to_string(),
672 "false".to_string(),
673 )]);
674 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
675 let config = validate_and_build(raw).unwrap();
676
677 assert!(!config.invite_code_required);
678 }
679
680 #[test]
681 fn env_override_invite_code_required_invalid_returns_error() {
682 let env = HashMap::from([(
683 "EZPDS_INVITE_CODE_REQUIRED".to_string(),
684 "maybe".to_string(),
685 )]);
686 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err();
687
688 assert!(matches!(err, ConfigError::Invalid(_)));
689 assert!(err.to_string().contains("EZPDS_INVITE_CODE_REQUIRED"));
690 }
691
692 #[test]
693 fn env_override_available_user_domains_comma_separated() {
694 let env = HashMap::from([(
695 "EZPDS_AVAILABLE_USER_DOMAINS".to_string(),
696 "foo.com, bar.com".to_string(),
697 )]);
698 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
699 let config = validate_and_build(raw).unwrap();
700
701 assert_eq!(config.available_user_domains, vec!["foo.com", "bar.com"]);
702 }
703
704 // --- telemetry config tests ---
705
706 #[test]
707 fn telemetry_defaults_to_disabled() {
708 let config = validate_and_build(minimal_raw()).unwrap();
709 assert!(!config.telemetry.enabled);
710 assert_eq!(config.telemetry.otlp_endpoint, "http://localhost:4317");
711 assert_eq!(config.telemetry.service_name, "ezpds-relay");
712 }
713
714 #[test]
715 fn parses_telemetry_section_from_toml() {
716 let toml = r#"
717 data_dir = "/var/pds"
718 public_url = "https://pds.example.com"
719 available_user_domains = ["example.com"]
720
721 [telemetry]
722 enabled = true
723 otlp_endpoint = "http://otel-collector:4317"
724 service_name = "my-pds"
725 "#;
726 let raw: RawConfig = toml::from_str(toml).unwrap();
727 let config = validate_and_build(raw).unwrap();
728
729 assert!(config.telemetry.enabled);
730 assert_eq!(config.telemetry.otlp_endpoint, "http://otel-collector:4317");
731 assert_eq!(config.telemetry.service_name, "my-pds");
732 }
733
734 #[test]
735 fn env_override_telemetry_enabled() {
736 let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "true".to_string())]);
737 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
738 let config = validate_and_build(raw).unwrap();
739
740 assert!(config.telemetry.enabled);
741 }
742
743 #[test]
744 fn env_override_otlp_endpoint() {
745 let env = HashMap::from([(
746 "EZPDS_OTLP_ENDPOINT".to_string(),
747 "http://custom:4317".to_string(),
748 )]);
749 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
750 let config = validate_and_build(raw).unwrap();
751
752 assert_eq!(config.telemetry.otlp_endpoint, "http://custom:4317");
753 }
754
755 #[test]
756 fn env_override_otel_service_name() {
757 let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "my-service".to_string())]);
758 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
759 let config = validate_and_build(raw).unwrap();
760
761 assert_eq!(config.telemetry.service_name, "my-service");
762 }
763
764 #[test]
765 fn otel_service_name_env_overrides_toml() {
766 let toml = r#"
767 data_dir = "/var/pds"
768 public_url = "https://pds.example.com"
769 available_user_domains = ["example.com"]
770
771 [telemetry]
772 service_name = "from-toml"
773 "#;
774 let raw: RawConfig = toml::from_str(toml).unwrap();
775 let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "from-env".to_string())]);
776 let raw = apply_env_overrides(raw, &env).unwrap();
777 let config = validate_and_build(raw).unwrap();
778
779 assert_eq!(config.telemetry.service_name, "from-env");
780 }
781
782 #[test]
783 fn env_override_telemetry_enabled_invalid_returns_error() {
784 let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "maybe".to_string())]);
785 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err();
786
787 assert!(matches!(err, ConfigError::Invalid(_)));
788 assert!(err.to_string().contains("EZPDS_TELEMETRY_ENABLED"));
789 }
790
791 // --- admin_token and signing_key_master_key config fields ---
792
793 #[test]
794 fn admin_token_is_optional() {
795 let config = validate_and_build(minimal_raw()).unwrap();
796 assert!(config.admin_token.is_none());
797 }
798
799 #[test]
800 fn signing_key_master_key_is_optional() {
801 let config = validate_and_build(minimal_raw()).unwrap();
802 assert!(config.signing_key_master_key.is_none());
803 }
804
805 #[test]
806 fn env_override_admin_token() {
807 let env = HashMap::from([("EZPDS_ADMIN_TOKEN".to_string(), "secret-token".to_string())]);
808 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
809 let config = validate_and_build(raw).unwrap();
810 assert_eq!(config.admin_token.as_deref(), Some("secret-token"));
811 }
812
813 #[test]
814 fn env_override_signing_key_master_key_valid_hex() {
815 // 64 valid hex chars → [u8; 32]
816 let hex_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
817 let env = HashMap::from([(
818 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(),
819 hex_key.to_string(),
820 )]);
821 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
822 let config = validate_and_build(raw).unwrap();
823
824 let expected: [u8; 32] = [
825 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
826 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
827 0x1d, 0x1e, 0x1f, 0x20,
828 ];
829 assert_eq!(
830 config.signing_key_master_key.as_ref().map(|s| &*s.0),
831 Some(&expected)
832 );
833 }
834
835 #[test]
836 fn env_override_signing_key_master_key_wrong_length_returns_error() {
837 // 62 hex chars (31 bytes) — wrong length
838 let short_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
839 let env = HashMap::from([(
840 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(),
841 short_key.to_string(),
842 )]);
843 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err();
844 assert!(matches!(err, ConfigError::Invalid(_)));
845 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY"));
846 }
847
848 #[test]
849 fn env_override_signing_key_master_key_non_hex_returns_error() {
850 // contains 'g' which is not a valid hex character
851 let invalid_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fgg";
852 let env = HashMap::from([(
853 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(),
854 invalid_key.to_string(),
855 )]);
856 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err();
857 assert!(matches!(err, ConfigError::Invalid(_)));
858 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY"));
859 }
860
861 #[test]
862 fn iroh_endpoint_parses_from_toml() {
863 let toml = r#"
864 data_dir = "/var/pds"
865 public_url = "https://pds.example.com"
866 available_user_domains = ["example.com"]
867
868 [iroh]
869 endpoint = "abc123nodeid"
870 "#;
871 let raw: RawConfig = toml::from_str(toml).unwrap();
872 let config = validate_and_build(raw).unwrap();
873 assert_eq!(config.iroh.endpoint, Some("abc123nodeid".to_string()));
874 }
875
876 #[test]
877 fn iroh_endpoint_defaults_to_none() {
878 let config = validate_and_build(minimal_raw()).unwrap();
879 assert_eq!(config.iroh.endpoint, None);
880 }
881
882 #[test]
883 fn env_override_iroh_endpoint() {
884 let env = HashMap::from([("EZPDS_IROH_ENDPOINT".to_string(), "nodeabc123".to_string())]);
885 let raw = apply_env_overrides(minimal_raw(), &env).unwrap();
886 let config = validate_and_build(raw).unwrap();
887 assert_eq!(config.iroh.endpoint, Some("nodeabc123".to_string()));
888 }
889
890 #[test]
891 fn iroh_endpoint_empty_string_returns_error() {
892 let mut raw = minimal_raw();
893 raw.iroh.endpoint = Some(String::new());
894 let err = validate_and_build(raw).unwrap_err();
895 assert!(matches!(err, ConfigError::Invalid(_)));
896 assert!(
897 err.to_string().contains("iroh.endpoint"),
898 "error message must mention iroh.endpoint"
899 );
900 }
901
902 #[test]
903 fn signing_key_master_key_in_toml_returns_error() {
904 // Operator mistakenly puts signing_key_master_key in relay.toml instead of env var.
905 // The sentinel field must catch this and reject the configuration.
906 let toml = r#"
907 data_dir = "/var/pds"
908 public_url = "https://pds.example.com"
909 available_user_domains = ["example.com"]
910 signing_key_master_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
911 "#;
912 let raw: RawConfig = toml::from_str(toml).unwrap();
913 let err = validate_and_build(raw).unwrap_err();
914
915 assert!(matches!(err, ConfigError::Invalid(_)));
916 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY"));
917 }
918}