use anyhow::Result; use async_trait::async_trait; use at_crypto::plc_op::PlcOperation; use reqwest::Client; use serde_json::Value; use crate::handle::DidHandleResolver; #[derive(Clone)] pub struct PlcClient { pub base_url: String, pub client: Client, } impl PlcClient { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), client: Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .unwrap(), } } pub async fn submit(&self, did: &str, op: &PlcOperation) -> Result { let url = format!("{}/{}", self.base_url, did); let body = serde_json::to_value(op)?; let resp = self.client.post(&url).json(&body).send().await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("plc submit failed: {} {}", status, text); } let v: Value = resp.json().await?; Ok(v.get("cid") .and_then(|x| x.as_str()) .unwrap_or_default() .to_string()) } /// Resolve `did:plc:` to its current handle by reading /// `//data` and pulling out the `handle` field. /// /// Only `did:plc:` is currently supported; `did:web:` and other /// methods return `Ok(None)` (the AppView's handle-sync worker treats /// `None` as "skip, try again later", not as an error). pub async fn resolve_handle(&self, did: &str) -> Result> { DidHandleResolver::resolve_handle(self, did).await } } #[async_trait] impl DidHandleResolver for PlcClient { async fn resolve_handle(&self, did: &str) -> Result> { // We only know how to look up PLC DIDs. Anything else (did:web:, // did:key:, etc.) is reported as "no handle available" rather // than an error. let rest = match did.strip_prefix("did:plc:") { Some(r) => r, None => return Ok(None), }; // Sanity-check the suffix so we don't construct weird URLs. if rest.is_empty() || rest.contains('/') { return Ok(None); } let url = format!("{}/{}/data", self.base_url, did); let resp = self.client.get(&url).send().await?; let status = resp.status(); if status.as_u16() == 404 { // DID exists syntactically but isn't registered. Not an error. return Ok(None); } if !status.is_success() { let text = resp.text().await.unwrap_or_default(); anyhow::bail!("plc lookup failed: {} {}", status, text); } let v: Value = resp.json().await?; // Modern PLC DID documents don't carry a top-level `handle` field // (deprecated in 2024); the handle is encoded as the first // `alsoKnownAs` AT URI: `at://`. We try both, preferring // `alsoKnownAs` so we handle current docs, then falling back to // the legacy `handle` field for older ones. if let Some(aka) = v.get("alsoKnownAs").and_then(|x| x.as_array()) { for entry in aka { if let Some(s) = entry.as_str() { if let Some(handle) = s.strip_prefix("at://") { if !handle.is_empty() { return Ok(Some(handle.to_string())); } } } } } Ok(v.get("handle") .and_then(|x| x.as_str()) .map(str::to_string)) } } pub async fn submit_op(client: &PlcClient, did: &str, op: &PlcOperation) -> Result { client.submit(did, op).await } #[cfg(test)] mod tests { use super::*; use std::time::Duration; /// A 404 from plc.directory (e.g. unknown DID) must come back as /// `Ok(None)` — never `Err(_)` — so the worker doesn't log it as a /// transient failure every pass. #[tokio::test] async fn resolve_handle_returns_none_on_404() { // Spin up a tiny mock server that always returns 404. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; loop { let (mut sock, _) = listener.accept().await.unwrap(); tokio::spawn(async move { // Read the request line + headers (don't care about body). let mut buf = vec![0u8; 1024]; let _ = sock.read(&mut buf).await; let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; let _ = sock.write_all(resp).await; }); } }); let client = PlcClient::new(format!("http://{addr}")); let r = tokio::time::timeout( Duration::from_secs(2), client.resolve_handle("did:plc:nobody"), ) .await .unwrap() .unwrap(); assert!(r.is_none(), "404 must map to Ok(None), got {r:?}"); server.abort(); } /// A 2xx response with the expected `handle` field should round-trip. #[tokio::test] async fn resolve_handle_parses_handle_field() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; loop { let (mut sock, _) = listener.accept().await.unwrap(); tokio::spawn(async move { let mut buf = vec![0u8; 1024]; let _ = sock.read(&mut buf).await; let body = br#"{"id":"did:plc:abc","handle":"alice.bsky.social"}"#; let resp = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len() ); let _ = sock.write_all(resp.as_bytes()).await; let _ = sock.write_all(body).await; }); } }); let client = PlcClient::new(format!("http://{addr}")); let r = tokio::time::timeout( Duration::from_secs(2), client.resolve_handle("did:plc:abc"), ) .await .unwrap() .unwrap(); assert_eq!(r.as_deref(), Some("alice.bsky.social")); server.abort(); } /// Modern PLC DID documents encode the handle in `alsoKnownAs[0]` as /// `at://` instead of a top-level field. Real-world docs /// (e.g. Bluesky's) look like this — must be parsed correctly. #[tokio::test] async fn resolve_handle_parses_alsoKnownAs() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; loop { let (mut sock, _) = listener.accept().await.unwrap(); tokio::spawn(async move { let mut buf = vec![0u8; 1024]; let _ = sock.read(&mut buf).await; let body = br#"{"did":"did:plc:abc","alsoKnownAs":["at://alice.bsky.social"],"services":{}}"#; let resp = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len() ); let _ = sock.write_all(resp.as_bytes()).await; let _ = sock.write_all(body).await; }); } }); let client = PlcClient::new(format!("http://{addr}")); let r = tokio::time::timeout( Duration::from_secs(2), client.resolve_handle("did:plc:abc"), ) .await .unwrap() .unwrap(); assert_eq!(r.as_deref(), Some("alice.bsky.social")); server.abort(); } /// `did:web:` is explicitly out of scope for now. Make sure we /// short-circuit with `Ok(None)` and never touch the network. #[tokio::test] async fn resolve_handle_skips_did_web() { // Construct a client pointed at an unreachable address — if our // implementation actually tried to hit it, this would time out. let client = PlcClient::new("http://127.0.0.1:1"); let r = tokio::time::timeout( Duration::from_millis(200), client.resolve_handle("did:web:example.com"), ) .await .expect("did:web must not block on the network") .unwrap(); assert!(r.is_none()); } /// Garbage DIDs (empty suffix, embedded slash) must be rejected /// without a network round-trip. #[tokio::test] async fn resolve_handle_rejects_garbage_did() { let client = PlcClient::new("http://127.0.0.1:1"); for bad in ["did:plc:", "did:plc:/etc/passwd"] { let r = client.resolve_handle(bad).await.unwrap(); assert!(r.is_none(), "{bad} must yield None, got {r:?}"); } } }