Something went wrong. Try again.
⚘ use your pds as a git remote if you want to ⚘
Something went wrong. Try again.
7.7 kB · 236 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237//! AT Protocol identity resolution.//!//! Resolves atproto handles to DIDs and DIDs to PDS service endpoints,//! following the standard AT Protocol identity resolution flow.//!//! Uses `atproto-identity` for spec-compliant handle resolution//! (DNS TXT + HTTPS well-known + XRPC fallback).
use atproto_identity::resolve::{HickoryDnsResolver, resolve_handle as upstream_resolve_handle};use serde::Deserialize;
/// Resolved identity: a DID and the PDS endpoint that hosts it.#[derive(Debug, Clone)]pub struct ResolvedIdentity { pub did: String, pub pds_url: String,}
/// Response from `com.atproto.identity.resolveHandle`.#[derive(Debug, Deserialize)]struct ResolveHandleResponse { did: String,}
/// A DID document returned by the PLC directory.#[derive(Debug, Deserialize)]struct DidDocument { #[serde(default)] service: Vec<DidService>,}
/// A service entry in a DID document.#[derive(Debug, Deserialize)]struct DidService { id: String, #[serde(rename = "type")] service_type: String, #[serde(rename = "serviceEndpoint")] service_endpoint: String,}
/// Default PLC directory URL.const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
/// Resolves an AT Protocol handle to a DID.////// When a known PDS URL is provided, uses the XRPC resolveHandle endpoint on/// that PDS directly. Otherwise delegates to `atproto-identity` which follows/// the ATProto spec (DNS TXT → HTTPS well-known → XRPC fallback).pub async fn resolve_handle(handle: &str, pds_url: Option<&str>) -> Result<String, String> { // if a PDS URL was provided, ask that PDS directly via XRPC if let Some(pds) = pds_url { return resolve_handle_xrpc(handle, pds).await; }
// use atproto-identity for spec-compliant resolution (DNS + well-known) let dns = HickoryDnsResolver::create_resolver(&[]); let http = reqwest::Client::new(); upstream_resolve_handle(&http, &dns, handle) .await .map_err(|e| format!("handle resolution failed: {}", e))}
/// Resolves a handle via XRPC on a specific PDS.////// Used when we already know which PDS to ask (e.g., from stored credentials).async fn resolve_handle_xrpc(handle: &str, base_url: &str) -> Result<String, String> { let http = reqwest::Client::new(); let base = base_url.trim_end_matches('/'); let url = format!( "{}/xrpc/com.atproto.identity.resolveHandle?handle={}", base, handle );
let resp = http .get(&url) .send() .await .map_err(|e| format!("resolveHandle request failed: {}", e))?;
if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("resolveHandle failed ({}): {}", status, body)); }
let parsed: ResolveHandleResponse = resp .json() .await .map_err(|e| format!("failed to parse resolveHandle response: {}", e))?;
Ok(parsed.did)}
/// Resolves a DID to its PDS service endpoint.////// Looks up the DID document from the PLC directory (for `did:plc`) or/// fetches the DID document directly (for `did:web`), then extracts the/// `#atproto_pds` service endpoint.pub async fn resolve_pds_endpoint( did: &str, plc_directory: Option<&str>,) -> Result<String, String> { let http = reqwest::Client::new(); let doc = fetch_did_document(&http, did, plc_directory).await?;
// find the atproto PDS service endpoint let pds_service = doc .service .iter() .find(|s| s.id == "#atproto_pds" && s.service_type == "AtprotoPersonalDataServer") .ok_or_else(|| format!("no #atproto_pds service found in DID document for {}", did))?;
Ok(pds_service .service_endpoint .trim_end_matches('/') .to_string())}
/// Resolves a handle to both its DID and PDS endpoint in one call.pub async fn resolve_identity( handle: &str, pds_url: Option<&str>, plc_directory: Option<&str>,) -> Result<ResolvedIdentity, String> { let did = resolve_handle(handle, pds_url).await?; let pds_url = resolve_pds_endpoint(&did, plc_directory).await?; Ok(ResolvedIdentity { did, pds_url })}
/// Fetches a DID document for the given DID.async fn fetch_did_document( http: &reqwest::Client, did: &str, plc_directory: Option<&str>,) -> Result<DidDocument, String> { let url = if did.starts_with("did:plc:") { let plc = plc_directory.unwrap_or(DEFAULT_PLC_DIRECTORY); format!("{}/{}", plc.trim_end_matches('/'), did) } else if did.starts_with("did:web:") { // did:web:example.com → https://example.com/.well-known/did.json let domain = did.strip_prefix("did:web:").unwrap(); format!("https://{}/.well-known/did.json", domain) } else { return Err(format!("unsupported DID method: {}", did)); };
let resp = http .get(&url) .send() .await .map_err(|e| format!("failed to fetch DID document for {}: {}", did, e))?;
if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!( "failed to fetch DID document for {} ({}): {}", did, status, body )); }
resp.json() .await .map_err(|e| format!("failed to parse DID document for {}: {}", did, e))}
#[cfg(test)]mod tests { use super::*;
#[test] fn did_plc_url_construction() { let did = "did:plc:abc123"; let url = format!("{}/{}", DEFAULT_PLC_DIRECTORY, did); assert_eq!(url, "https://plc.directory/did:plc:abc123"); }
#[test] fn did_web_url_construction() { let did = "did:web:example.com"; let domain = did.strip_prefix("did:web:").unwrap(); let url = format!("https://{}/.well-known/did.json", domain); assert_eq!(url, "https://example.com/.well-known/did.json"); }
#[test] fn parse_did_document_with_pds_service() { let json = serde_json::json!({ "id": "did:plc:abc123", "service": [ { "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://pds.example.com" } ] });
let doc: DidDocument = serde_json::from_value(json).unwrap(); assert_eq!(doc.service.len(), 1); assert_eq!(doc.service[0].id, "#atproto_pds"); assert_eq!(doc.service[0].service_endpoint, "https://pds.example.com"); }
/// Tests DNS-based handle resolution (requires network). /// Run with: cargo test -- --ignored resolve_dns_handle #[tokio::test] #[ignore] async fn resolve_dns_handle() { // bsky.app uses DNS TXT for handle resolution let did = resolve_handle("bsky.app", None).await.unwrap(); assert!(did.starts_with("did:"), "expected DID, got: {}", did); println!("bsky.app resolved to: {}", did); }
/// Tests that standard .bsky.social handles still work (XRPC fallback). #[tokio::test] #[ignore] async fn resolve_bsky_social_handle() { let did = resolve_handle("atproto.com", None).await.unwrap(); assert!(did.starts_with("did:"), "expected DID, got: {}", did); println!("atproto.com resolved to: {}", did); }
#[test] fn parse_did_document_without_pds_service() { let json = serde_json::json!({ "id": "did:plc:abc123", "service": [] });
let doc: DidDocument = serde_json::from_value(json).unwrap(); assert!(doc.service.is_empty()); }}