use affinidi_did_resolver_cache_sdk::{DIDCacheClient, errors::DIDCacheError};
use affinidi_tdk::did_common::Document;
use agent_names::AgentName;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
const MAX_CANDIDATES: usize = 4;
pub const AGENT_NAME_TTL: Duration = Duration::hours(24);
pub const AGENT_NAME_NEGATIVE_TTL: Duration = Duration::minutes(5);
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CachedAgentName {
pub name: Option<String>,
pub checked_at: DateTime<Utc>,
}
impl CachedAgentName {
#[must_use]
pub fn ttl(&self) -> Duration {
if self.name.is_some() {
AGENT_NAME_TTL
} else {
AGENT_NAME_NEGATIVE_TTL
}
}
#[must_use]
pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
now - self.checked_at >= self.ttl()
}
}
#[must_use]
pub fn looks_like_agent_name(input: &str) -> bool {
AgentName::looks_like_agent_name(input)
}
pub async fn verified_agent_name(
resolver: &DIDCacheClient,
did: &str,
doc: &Document,
) -> Option<String> {
match name_outcome(resolver, did, doc).await {
NameOutcome::Verified(name) => Some(name),
NameOutcome::NoClaim | NameOutcome::NotVerified(_) => None,
}
}
pub async fn name_outcome(resolver: &DIDCacheClient, did: &str, doc: &Document) -> NameOutcome {
let candidates = agent_names::extract_agent_names(doc);
verify_candidates_detailed(did, candidates, |name| async move {
resolver
.resolve_any(&name)
.await
.map(|resp| resp.did)
.map_err(|e| e.to_string())
})
.await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameOutcome {
Verified(String),
NoClaim,
NotVerified(Vec<CandidateFailure>),
}
impl NameOutcome {
#[must_use]
pub fn name(&self) -> Option<&str> {
match self {
NameOutcome::Verified(n) => Some(n),
_ => None,
}
}
#[must_use]
pub fn summary(&self) -> String {
match self {
NameOutcome::Verified(n) => format!("verified '{n}'"),
NameOutcome::NoClaim => "no agent name claimed in the document".to_string(),
NameOutcome::NotVerified(failures) => {
let detail = failures
.iter()
.map(|f| format!("'{}' ({})", f.name, f.reason))
.collect::<Vec<_>>()
.join("; ");
format!("claimed but not verified: {detail}")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CandidateFailure {
pub name: String,
pub reason: String,
}
pub async fn resolve_verified_name(resolver: &DIDCacheClient, did: &str) -> Option<String> {
match resolve_name_outcome(resolver, did).await {
Ok(outcome) => outcome.name().map(str::to_owned),
Err(_) => None,
}
}
pub async fn resolve_name_outcome(
resolver: &DIDCacheClient,
did: &str,
) -> Result<NameOutcome, String> {
let resp = resolver.resolve(did).await.map_err(|e| e.to_string())?;
Ok(name_outcome(resolver, did, &resp.doc).await)
}
async fn verify_candidates_detailed<F, Fut>(
did: &str,
candidates: Vec<AgentName>,
resolve: F,
) -> NameOutcome
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<String, String>>,
{
if candidates.is_empty() {
return NameOutcome::NoClaim;
}
let mut failures = Vec::new();
for name in candidates.into_iter().take(MAX_CANDIDATES) {
match resolve(name.as_str().to_string()).await {
Ok(got) if got == did => {
return NameOutcome::Verified(name.without_scheme().to_string());
}
Ok(got) => failures.push(CandidateFailure {
name: name.without_scheme().to_string(),
reason: format!("forward-resolves to a different DID ({got})"),
}),
Err(reason) => failures.push(CandidateFailure {
name: name.without_scheme().to_string(),
reason,
}),
}
}
NameOutcome::NotVerified(failures)
}
#[derive(Debug, thiserror::Error)]
pub enum IdentifierError {
#[error("agent name '{input}' could not be resolved: {detail}")]
AgentName { input: String, detail: String },
#[error("could not reach the network resolving '{input}'")]
Unreachable { input: String },
#[error("'{input}' is not a resolvable DID")]
InvalidDid { input: String },
}
pub async fn resolve_identifier(
resolver: &DIDCacheClient,
input: &str,
) -> Result<String, IdentifierError> {
let input = input.trim();
match resolver.resolve_any(input).await {
Ok(resp) => Ok(resp.did),
Err(e) => Err(classify(input, e)),
}
}
fn classify(input: &str, err: DIDCacheError) -> IdentifierError {
let input = input.to_string();
match err {
DIDCacheError::AgentNameError(detail) => IdentifierError::AgentName { input, detail },
DIDCacheError::NetworkTimeout | DIDCacheError::TransportError(_) => {
IdentifierError::Unreachable { input }
}
other => {
if looks_like_agent_name(&input) {
IdentifierError::AgentName {
input,
detail: other.to_string(),
}
} else {
IdentifierError::InvalidDid { input }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn name(s: &str) -> AgentName {
AgentName::parse(s).expect("valid agent name in test")
}
#[test]
fn cache_entry_staleness_tracks_the_ttl() {
let checked_at = Utc::now();
let fresh = CachedAgentName {
name: Some("example.com/@alice".into()),
checked_at,
};
assert!(!fresh.is_stale(checked_at));
assert!(!fresh.is_stale(checked_at + Duration::hours(23)));
assert!(fresh.is_stale(checked_at + AGENT_NAME_TTL));
assert!(fresh.is_stale(checked_at + Duration::hours(25)));
}
#[test]
fn negative_entries_expire_far_sooner_than_positive_ones() {
let checked_at = Utc::now();
let negative = CachedAgentName {
name: None,
checked_at,
};
assert_eq!(negative.ttl(), AGENT_NAME_NEGATIVE_TTL);
assert!(!negative.is_stale(checked_at));
assert!(negative.is_stale(checked_at + AGENT_NAME_NEGATIVE_TTL));
let positive = CachedAgentName {
name: Some("example.com/@alice".into()),
checked_at,
};
let after = checked_at + AGENT_NAME_NEGATIVE_TTL;
assert!(negative.is_stale(after));
assert!(!positive.is_stale(after));
}
#[tokio::test]
async fn rejects_name_resolving_to_a_different_did() {
let got = verify_candidates_detailed(
"did:webvh:us:example.com",
vec![name("example.com/@alice")],
|_| async { Ok("did:webvh:them:evil.example".to_string()) },
)
.await;
assert_eq!(
got.name(),
None,
"a name pointing at a different DID must be rejected"
);
match &got {
NameOutcome::NotVerified(failures) => {
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].name, "example.com/@alice");
assert!(
failures[0].reason.contains("did:webvh:them:evil.example"),
"reason was: {}",
failures[0].reason
);
}
other => panic!("expected NotVerified, got {other:?}"),
}
}
#[tokio::test]
async fn accepts_name_that_round_trips() {
let did = "did:webvh:us:example.com";
let got = verify_candidates_detailed(did, vec![name("example.com/@alice")], |n| {
let did = did.to_string();
async move {
assert_eq!(n, "https://example.com/@alice");
Ok(did)
}
})
.await;
assert_eq!(got.name(), Some("example.com/@alice"));
}
#[tokio::test]
async fn skips_unresolvable_name() {
let got = verify_candidates_detailed(
"did:webvh:us:example.com",
vec![name("example.com/@alice")],
|_| async { Err("connection refused".to_string()) },
)
.await;
assert_eq!(got.name(), None);
assert!(got.summary().contains("connection refused"), "{got:?}");
}
#[tokio::test]
async fn caps_the_number_of_candidates_verified() {
let calls = Cell::new(0usize);
let candidates: Vec<AgentName> = (0..(MAX_CANDIDATES + 5))
.map(|i| name(&format!("example.com/@name{i}")))
.collect();
let got = verify_candidates_detailed("did:webvh:us:example.com", candidates, |_| {
calls.set(calls.get() + 1);
async { Ok("did:webvh:other:host".to_string()) }
})
.await;
assert_eq!(got.name(), None);
assert_eq!(calls.get(), MAX_CANDIDATES, "must not resolve past the cap");
}
#[tokio::test]
async fn stops_at_first_verified_candidate() {
let did = "did:webvh:us:example.com";
let calls = Cell::new(0usize);
let candidates = vec![name("example.com/@first"), name("example.com/@second")];
let got = verify_candidates_detailed(did, candidates, |_| {
calls.set(calls.get() + 1);
let did = did.to_string();
async move { Ok(did) }
})
.await;
assert_eq!(got.name(), Some("example.com/@first"));
assert_eq!(calls.get(), 1, "must short-circuit on the first match");
}
#[tokio::test]
async fn no_claimed_names_reports_no_claim() {
let got = verify_candidates_detailed("did:webvh:us:example.com", vec![], |_| async {
panic!("must not resolve anything when there are no candidates")
})
.await;
assert_eq!(got, NameOutcome::NoClaim);
assert!(got.summary().contains("no agent name claimed"));
}
#[test]
fn recognises_agent_names() {
assert!(looks_like_agent_name("example.com/@alice"));
assert!(looks_like_agent_name("https://connect.me/@bob"));
assert!(looks_like_agent_name(
"firstperson.network/@drummond/h2hsummit"
));
}
#[test]
fn rejects_non_agent_names() {
assert!(!looks_like_agent_name("did:webvh:QmScid:example.com"));
assert!(!looks_like_agent_name("did:web:example.com"));
assert!(!looks_like_agent_name("alice@example.com"));
assert!(!looks_like_agent_name("@alice"));
assert!(!looks_like_agent_name(""));
}
#[test]
fn classify_non_name_did_error_is_invalid_did() {
let err = classify("not-a-did", DIDCacheError::DIDError("bad".into()));
assert!(matches!(err, IdentifierError::InvalidDid { .. }));
}
#[test]
fn classify_name_shaped_parse_error_is_agent_name() {
let err = classify("example.com/@alice", DIDCacheError::DIDError("bad".into()));
assert!(matches!(err, IdentifierError::AgentName { .. }));
}
#[test]
fn classify_network_errors_are_unreachable() {
assert!(matches!(
classify("example.com/@alice", DIDCacheError::NetworkTimeout),
IdentifierError::Unreachable { .. }
));
assert!(matches!(
classify("did:web:x", DIDCacheError::TransportError("refused".into())),
IdentifierError::Unreachable { .. }
));
}
#[test]
fn classify_agent_name_error_preserves_detail() {
let err = classify(
"example.com/@alice",
DIDCacheError::AgentNameError(
"Agent name 'example.com/@alice' did not redirect (HTTP 404)".into(),
),
);
match err {
IdentifierError::AgentName { detail, .. } => {
assert!(detail.contains("did not redirect"), "detail was: {detail}");
}
other => panic!("expected AgentName, got {other:?}"),
}
}
}