A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125//! Resolution of AT Protocol account identifiers (handle or DID) to a DID.//!//! Every surface that accepts "an account" from an operator accepts either//! form, so the normalization has to live in one place. Storing an//! unresolved handle where a DID is expected is silently broken rather than//! loudly broken: authorization checks compare against the DID in an OAuth//! session, so the row simply never matches and the account cannot sign in//! (issue #85). Resolving at the point of entry keeps that failure at the//! point of the mistake.
use std::sync::Arc;
use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig};
use crate::dns::NativeDnsResolver;use crate::error::AppError;
/// The shared handle resolver, wired to HappyView's own DNS resolver and/// retrying HTTP client.pub fn handle_resolver()-> AtprotoHandleResolver<NativeDnsResolver, crate::http_retry::HappyViewHttpClient> { AtprotoHandleResolver::new(AtprotoHandleResolverConfig { dns_txt_resolver: NativeDnsResolver::new(), http_client: Arc::new(crate::http_retry::HappyViewHttpClient::new( crate::http_retry::shared_client().clone(), )), })}
pub struct ResolvedIdentifier { pub did: String, /// Set only when the caller supplied a handle — a bare DID tells us /// nothing about which handle currently points at it. pub handle: Option<String>,}
/// Resolve an account identifier to a DID.////// A `did:` prefix is validated for shape and passed through. Anything else is/// treated as a handle: validated, then resolved via DNS TXT `_atproto.<handle>`/// with an HTTPS `.well-known/atproto-did` fallback. A leading `@` is accepted/// because operators paste handles that way.////// Both failure modes are `BadRequest` — the input is at fault, not the server.pub async fn resolve_identifier(input: &str) -> Result<ResolvedIdentifier, AppError> { let input = input.trim();
if input.is_empty() { return Err(AppError::BadRequest( "expected a handle or DID, got an empty value".into(), )); }
if input.starts_with("did:") { let did = atrium_api::types::string::Did::new(input.to_string()) .map_err(|e| AppError::BadRequest(format!("invalid DID {input}: {e}")))?; return Ok(ResolvedIdentifier { did: did.as_str().to_string(), handle: None, }); }
let handle_str = input.trim_start_matches('@').to_string(); let handle = atrium_api::types::string::Handle::new(handle_str.clone()) .map_err(|_| AppError::BadRequest(format!("invalid handle: {input}")))?;
use atrium_common::resolver::Resolver; let did = handle_resolver() .resolve(&handle) .await .map_err(|e| AppError::BadRequest(format!("could not resolve handle {handle_str}: {e}")))?;
Ok(ResolvedIdentifier { did: did.as_ref().to_string(), handle: Some(handle_str), })}
#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn a_bare_did_passes_through_with_no_handle() { let resolved = resolve_identifier("did:plc:abc123").await.unwrap(); assert_eq!(resolved.did, "did:plc:abc123"); assert!(resolved.handle.is_none()); }
#[tokio::test] async fn surrounding_whitespace_is_ignored() { let resolved = resolve_identifier(" did:plc:abc123 ").await.unwrap(); assert_eq!(resolved.did, "did:plc:abc123"); }
#[tokio::test] async fn an_empty_identifier_is_refused() { assert!(matches!( resolve_identifier(" ").await, Err(AppError::BadRequest(_)) )); }
/// Refused on shape, before any DNS or HTTP work is attempted. #[tokio::test] async fn a_malformed_handle_is_refused_without_network_access() { for bad in ["not a handle", "@", "no-dot", "trailing-.", "-leading.com"] { assert!( matches!(resolve_identifier(bad).await, Err(AppError::BadRequest(_))), "expected {bad:?} to be refused" ); } }
#[tokio::test] async fn a_malformed_did_is_refused() { for bad in ["did:", "did:plc:"] { assert!( matches!(resolve_identifier(bad).await, Err(AppError::BadRequest(_))), "expected {bad:?} to be refused" ); } }}