use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StoreConfig {
pub data_dir: PathBuf,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AuthConfig {
#[serde(default = "default_access_token_expiry")]
pub access_token_expiry: u64,
#[serde(default = "default_refresh_token_expiry")]
pub refresh_token_expiry: u64,
#[serde(default = "default_challenge_ttl")]
pub challenge_ttl: u64,
#[serde(default = "default_admin_idle_timeout")]
pub admin_idle_timeout: u64,
#[serde(default = "default_refresh_reuse_grace")]
pub refresh_reuse_grace: u64,
#[serde(default = "default_session_cleanup_interval")]
pub session_cleanup_interval: u64,
pub jwt_signing_key: Option<String>,
#[serde(default, deserialize_with = "refuse_retired_step_up", skip_serializing)]
pub step_up: (),
}
fn refuse_retired_step_up<'de, D>(_: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
Err(serde::de::Error::custom(
"`[auth.step_up]` has been retired. The step-up floors were a second, \
parallel answer to \"does this operation need another human decision?\", \
resolved separately from the policy rules — which is how a VTA could \
demand a step-up that no rule explained. Approvals are now one model: \
delete the `[auth.step_up]` section and express the same requirement as \
a rule with `pnm approvals require <task-uri> --reauth` (or \
`--consent`). `pnm approvals list` then shows every gated operation, \
which the floors never could.",
))
}
impl std::fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthConfig")
.field("access_token_expiry", &self.access_token_expiry)
.field("refresh_token_expiry", &self.refresh_token_expiry)
.field("challenge_ttl", &self.challenge_ttl)
.field("admin_idle_timeout", &self.admin_idle_timeout)
.field("session_cleanup_interval", &self.session_cleanup_interval)
.field(
"jwt_signing_key",
&self.jwt_signing_key.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessagingConfig {
#[serde(default)]
pub mediator_url: String,
pub mediator_did: String,
#[serde(default)]
pub mediator_host: Option<String>,
#[serde(default)]
pub setup_acl: bool,
#[serde(default)]
pub drain_inbox_on_start: bool,
}
pub const DID_CACHE_TTL_DEFAULT_SECS: u32 = 60;
pub const DID_CACHE_TTL_MAX_SECS: u32 = 300;
pub const DID_CACHE_CAPACITY_DEFAULT: u32 = 1000;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DidCacheConfig {
#[serde(default = "default_did_cache_ttl_secs")]
pub ttl_secs: u32,
#[serde(default = "default_did_cache_capacity")]
pub capacity: u32,
}
fn default_did_cache_ttl_secs() -> u32 {
DID_CACHE_TTL_DEFAULT_SECS
}
fn default_did_cache_capacity() -> u32 {
DID_CACHE_CAPACITY_DEFAULT
}
impl Default for DidCacheConfig {
fn default() -> Self {
Self {
ttl_secs: DID_CACHE_TTL_DEFAULT_SECS,
capacity: DID_CACHE_CAPACITY_DEFAULT,
}
}
}
impl DidCacheConfig {
pub fn validation_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.ttl_secs == 0 || self.ttl_secs > DID_CACHE_TTL_MAX_SECS {
errors.push(format!(
"did_cache.ttl_secs = {} is outside 1..={DID_CACHE_TTL_MAX_SECS}: it bounds how \
long a revoked key keeps verifying on this node, so it may not be longer than \
{DID_CACHE_TTL_MAX_SECS} seconds (and 0 would disable the cache the node relies \
on). The default is {DID_CACHE_TTL_DEFAULT_SECS}.",
self.ttl_secs
));
}
if self.capacity == 0 {
errors.push(
"did_cache.capacity = 0 would cache nothing and resolve every DID on every \
request; remove the key for the default"
.into(),
);
}
errors
}
}
#[cfg(test)]
mod did_cache_config_tests {
use super::*;
#[test]
fn defaults_are_bounded_and_valid() {
let c: DidCacheConfig = toml::from_str("").unwrap();
assert_eq!(c.ttl_secs, DID_CACHE_TTL_DEFAULT_SECS);
assert!(c.ttl_secs <= DID_CACHE_TTL_MAX_SECS);
assert!(c.validation_errors().is_empty());
}
#[test]
fn a_ttl_past_the_bound_or_zero_is_refused() {
for ttl in [0, DID_CACHE_TTL_MAX_SECS + 1, 86_400] {
let c = DidCacheConfig {
ttl_secs: ttl,
..Default::default()
};
assert_eq!(c.validation_errors().len(), 1, "ttl {ttl}");
}
let c = DidCacheConfig {
ttl_secs: DID_CACHE_TTL_MAX_SECS,
..Default::default()
};
assert!(c.validation_errors().is_empty());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditConfig {
#[serde(default = "default_audit_retention_days")]
pub retention_days: u32,
}
fn default_audit_retention_days() -> u32 {
28
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
retention_days: default_audit_retention_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultConfig {
#[serde(default = "default_vault_grace_days")]
pub grace_days: u32,
#[serde(default)]
pub mdoc_iaca_trust_anchors: Vec<String>,
}
fn default_vault_grace_days() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStateConfig {
#[serde(default = "default_tombstone_retention_days")]
pub tombstone_retention_days: u32,
}
fn default_tombstone_retention_days() -> u32 {
30
}
impl Default for AppStateConfig {
fn default() -> Self {
Self {
tombstone_retention_days: default_tombstone_retention_days(),
}
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
grace_days: default_vault_grace_days(),
mdoc_iaca_trust_anchors: Vec::new(),
}
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_access_token_expiry() -> u64 {
900
}
fn default_refresh_token_expiry() -> u64 {
86400
}
fn default_challenge_ttl() -> u64 {
300
}
fn default_admin_idle_timeout() -> u64 {
900
}
fn default_session_cleanup_interval() -> u64 {
600
}
fn default_refresh_reuse_grace() -> u64 {
30
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
access_token_expiry: default_access_token_expiry(),
refresh_token_expiry: default_refresh_token_expiry(),
challenge_ttl: default_challenge_ttl(),
admin_idle_timeout: default_admin_idle_timeout(),
refresh_reuse_grace: default_refresh_reuse_grace(),
session_cleanup_interval: default_session_cleanup_interval(),
jwt_signing_key: None,
step_up: (),
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_config_debug_redacts_jwt_signing_key() {
let cfg = AuthConfig {
access_token_expiry: 900,
refresh_token_expiry: 86400,
challenge_ttl: 300,
admin_idle_timeout: 900,
refresh_reuse_grace: 30,
session_cleanup_interval: 600,
jwt_signing_key: Some("SUPER_SECRET_KEY_MATERIAL_MUST_NOT_LEAK".into()),
step_up: (),
};
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains("SUPER_SECRET_KEY_MATERIAL"),
"AuthConfig Debug leaked jwt_signing_key contents: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"expected redaction marker in Debug, got: {dbg}"
);
assert!(
dbg.contains("900"),
"access_token_expiry must still be visible: {dbg}"
);
}
#[test]
fn auth_config_debug_none_signing_key_renders_none() {
let cfg = AuthConfig::default();
let dbg = format!("{cfg:?}");
assert!(dbg.contains("jwt_signing_key: None"), "got: {dbg}");
}
#[test]
fn auth_config_serialize_still_carries_jwt_signing_key() {
let cfg = AuthConfig {
access_token_expiry: 900,
refresh_token_expiry: 86400,
challenge_ttl: 300,
admin_idle_timeout: 900,
refresh_reuse_grace: 30,
session_cleanup_interval: 600,
jwt_signing_key: Some("key-material".into()),
step_up: (),
};
let json = serde_json::to_string(&cfg).expect("serialize");
assert!(
json.contains("key-material"),
"Serialize must not redact — config persistence relies on round-trip: {json}"
);
}
#[test]
fn a_config_still_carrying_the_retired_floors_is_refused() {
let with_floors = r#"{
"jwt_signing_key": null,
"step_up": { "enabled": true, "floors": [{ "operation": "*", "mode": "self" }] }
}"#;
let err = serde_json::from_str::<AuthConfig>(with_floors)
.expect_err("`[auth.step_up]` must be refused, not ignored");
let msg = err.to_string();
assert!(msg.contains("retired"), "got: {msg}");
assert!(
msg.contains("pnm approvals require"),
"the refusal must name what replaces it, got: {msg}"
);
assert!(
serde_json::from_str::<AuthConfig>(r#"{"jwt_signing_key":null,"step_up":{}}"#).is_err()
);
}
#[test]
fn a_config_without_the_retired_section_loads() {
let cfg: AuthConfig =
serde_json::from_str(r#"{ "jwt_signing_key": null }"#).expect("loads");
assert_eq!(cfg.access_token_expiry, default_access_token_expiry());
}
}
#[cfg(test)]
mod mdoc_trust_anchor_config_tests {
use super::*;
#[test]
fn trust_anchors_default_to_empty_and_an_old_config_still_loads() {
let cfg: VaultConfig = toml::from_str("grace_days = 30").expect("legacy config loads");
assert_eq!(cfg.grace_days, 30);
assert!(
cfg.mdoc_iaca_trust_anchors.is_empty(),
"absent means no mdoc issuer is trusted, not a permissive default"
);
}
#[test]
fn app_state_config_defaults_when_absent() {
let cfg: AppStateConfig = toml::from_str("").expect("an absent section loads");
assert_eq!(cfg.tombstone_retention_days, 30);
assert_eq!(AppStateConfig::default().tombstone_retention_days, 30);
}
#[test]
fn app_state_retention_zero_survives_as_zero() {
let cfg: AppStateConfig =
toml::from_str("tombstone_retention_days = 0").expect("explicit zero loads");
assert_eq!(
cfg.tombstone_retention_days, 0,
"an explicit 0 must not be rewritten to the default"
);
}
#[test]
fn trust_anchors_round_trip_through_toml() {
let cfg: VaultConfig = toml::from_str(
r#"
grace_days = 7
mdoc_iaca_trust_anchors = ["-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"]
"#,
)
.expect("config with anchors loads");
assert_eq!(cfg.mdoc_iaca_trust_anchors.len(), 1);
assert!(cfg.mdoc_iaca_trust_anchors[0].contains("BEGIN CERTIFICATE"));
}
}