use std::collections::BTreeMap;
use std::time::Duration;
use serde::Deserialize;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Default, Deserialize)]
pub struct XrpcGatewayConfigToml {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub service_did: Option<String>,
#[serde(default)]
pub clock_skew_tolerance_seconds: Option<u32>,
#[serde(default)]
pub replay_cache_ttl_seconds: Option<u32>,
#[serde(flatten, default)]
pub extras: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct XrpcGatewayConfig {
pub enabled: bool,
pub service_did: String,
pub clock_skew_tolerance: Duration,
pub replay_cache_ttl: Duration,
}
impl XrpcGatewayConfig {
pub fn from_config(cfg: &crate::config::Config) -> Result<Option<Self>> {
let Some(toml) = cfg.xrpc_gateway.as_ref() else {
return Ok(None);
};
Self::from_toml(toml)
}
pub fn from_toml(toml: &XrpcGatewayConfigToml) -> Result<Option<Self>> {
if !toml.enabled {
reject_extras_pointing_to_typos(&toml.extras)?;
return Ok(None);
}
reject_extras_pointing_to_typos(&toml.extras)?;
let service_did = toml.service_did.as_ref().ok_or_else(|| {
Error::Config("[xrpc_gateway].service_did is required when enabled = true".into())
})?;
validate_did(service_did)?;
let skew_secs = toml.clock_skew_tolerance_seconds.unwrap_or(30);
if !(1..=300).contains(&skew_secs) {
return Err(Error::Config(format!(
"[xrpc_gateway].clock_skew_tolerance_seconds = {skew_secs} \
is out of range (must be 1..=300)"
)));
}
let ttl_secs = toml.replay_cache_ttl_seconds.unwrap_or(90);
let min_ttl = u64::from(skew_secs) + 60;
if u64::from(ttl_secs) < min_ttl {
return Err(Error::Config(format!(
"[xrpc_gateway].replay_cache_ttl_seconds = {ttl_secs} is below the \
minimum {min_ttl} (= clock_skew_tolerance_seconds + 60). \
The cache must outlive any JWT we'd accept; bsky-PDS mints \
60-second-TTL JWTs per findings §2.2."
)));
}
Ok(Some(Self {
enabled: true,
service_did: service_did.clone(),
clock_skew_tolerance: Duration::from_secs(u64::from(skew_secs)),
replay_cache_ttl: Duration::from_secs(u64::from(ttl_secs)),
}))
}
}
fn validate_did(did: &str) -> Result<()> {
if !did.starts_with("did:") {
return Err(Error::Config(format!(
"[xrpc_gateway].service_did must start with 'did:'; got {did:?}"
)));
}
let rest = &did["did:".len()..];
let mut parts = rest.splitn(2, ':');
let method = parts.next().unwrap_or("");
let identifier = parts.next().unwrap_or("");
if method.is_empty() || identifier.is_empty() {
return Err(Error::Config(format!(
"[xrpc_gateway].service_did must be 'did:<method>:<identifier>'; got {did:?}"
)));
}
if did.chars().any(char::is_whitespace) {
return Err(Error::Config(format!(
"[xrpc_gateway].service_did contains whitespace: {did:?}"
)));
}
Ok(())
}
fn reject_extras_pointing_to_typos(extras: &BTreeMap<String, serde_json::Value>) -> Result<()> {
if extras.is_empty() {
return Ok(());
}
let keys = extras
.keys()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
Err(Error::Config(format!(
"[xrpc_gateway] has unknown key(s): {keys}. \
v1.7 supports `enabled`, `service_did`, \
`clock_skew_tolerance_seconds`, `replay_cache_ttl_seconds`. \
Check spelling — typos surface here rather than being \
silently ignored."
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_toml() -> XrpcGatewayConfigToml {
XrpcGatewayConfigToml {
enabled: true,
service_did: Some("did:web:cairn.example.com".into()),
clock_skew_tolerance_seconds: None,
replay_cache_ttl_seconds: None,
extras: BTreeMap::new(),
}
}
#[test]
fn disabled_config_returns_none_no_field_validation() {
let toml = XrpcGatewayConfigToml {
enabled: false,
service_did: Some("not-a-did".into()),
clock_skew_tolerance_seconds: Some(0),
replay_cache_ttl_seconds: Some(0),
extras: BTreeMap::new(),
};
let result = XrpcGatewayConfig::from_toml(&toml).unwrap();
assert!(result.is_none(), "disabled gateway resolves to None");
}
#[test]
fn enabled_with_valid_fields_returns_some_with_defaults_filled() {
let toml = enabled_toml();
let cfg = XrpcGatewayConfig::from_toml(&toml).unwrap().unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.service_did, "did:web:cairn.example.com");
assert_eq!(cfg.clock_skew_tolerance, Duration::from_secs(30));
assert_eq!(cfg.replay_cache_ttl, Duration::from_secs(90));
}
#[test]
fn enabled_without_service_did_errors() {
let mut toml = enabled_toml();
toml.service_did = None;
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("service_did is required"), "{msg}");
}
#[test]
fn enabled_with_malformed_did_errors() {
let cases = [
"not-a-did",
"did:",
"did:method", "did:method:", "did::identifier", "did:web: contains space",
];
for input in cases {
let mut toml = enabled_toml();
toml.service_did = Some(input.into());
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("service_did") && msg.contains(input),
"expected service_did rejection for {input:?}; got {msg}"
);
}
}
#[test]
fn clock_skew_zero_errors() {
let mut toml = enabled_toml();
toml.clock_skew_tolerance_seconds = Some(0);
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
assert!(err.to_string().contains("clock_skew_tolerance_seconds"));
}
#[test]
fn clock_skew_above_max_errors() {
let mut toml = enabled_toml();
toml.clock_skew_tolerance_seconds = Some(301);
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("clock_skew_tolerance_seconds"));
assert!(msg.contains("301"));
}
#[test]
fn replay_cache_below_min_errors() {
let mut toml = enabled_toml();
toml.replay_cache_ttl_seconds = Some(89);
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("replay_cache_ttl_seconds"));
assert!(msg.contains("89"));
assert!(msg.contains("90")); }
#[test]
fn replay_cache_min_relative_to_custom_skew() {
let mut toml = enabled_toml();
toml.clock_skew_tolerance_seconds = Some(60);
toml.replay_cache_ttl_seconds = Some(119);
assert!(XrpcGatewayConfig::from_toml(&toml).is_err());
toml.replay_cache_ttl_seconds = Some(120);
let cfg = XrpcGatewayConfig::from_toml(&toml).unwrap().unwrap();
assert_eq!(cfg.replay_cache_ttl, Duration::from_secs(120));
assert_eq!(cfg.clock_skew_tolerance, Duration::from_secs(60));
}
#[test]
fn unknown_extras_key_rejected_with_helpful_message() {
let mut toml = enabled_toml();
toml.extras
.insert("clock_skew_seconds".into(), serde_json::json!(30));
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("clock_skew_seconds"),
"error names the offending key: {msg}"
);
assert!(
msg.contains("clock_skew_tolerance_seconds"),
"error suggests valid keys: {msg}"
);
}
#[test]
fn extras_rejected_even_when_disabled() {
let toml = XrpcGatewayConfigToml {
enabled: false,
service_did: None,
clock_skew_tolerance_seconds: None,
replay_cache_ttl_seconds: None,
extras: {
let mut m = BTreeMap::new();
m.insert("bogus_field".into(), serde_json::json!("value"));
m
},
};
let err = XrpcGatewayConfig::from_toml(&toml).unwrap_err();
assert!(err.to_string().contains("bogus_field"));
}
#[test]
fn boundary_clock_skew_values_accepted() {
let mut toml = enabled_toml();
toml.clock_skew_tolerance_seconds = Some(1);
toml.replay_cache_ttl_seconds = Some(61);
assert!(XrpcGatewayConfig::from_toml(&toml).is_ok());
toml.clock_skew_tolerance_seconds = Some(300);
toml.replay_cache_ttl_seconds = Some(360);
assert!(XrpcGatewayConfig::from_toml(&toml).is_ok());
}
}