use std::sync::Arc;
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::lexicon::{self, Folder, ReadState, Saved, Subscription};
pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
pub const DEFAULT_RESOLVER_HOST: &str = "https://bsky.social";
#[derive(Debug, thiserror::Error)]
pub enum AtProtoError {
#[error("atproto transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("atproto XRPC error {status}: {error}{}", .message.as_deref().map(|m| format!(" — {m}")).unwrap_or_default())]
Xrpc {
status: StatusCode,
error: String,
message: Option<String>,
},
#[error("could not resolve handle {handle:?} to a DID")]
HandleResolution {
handle: String,
},
#[error("could not resolve DID {did:?} to a PDS endpoint: {reason}")]
DidResolution {
did: String,
reason: String,
},
}
impl AtProtoError {
pub fn is_record_not_found(&self) -> bool {
matches!(
self,
AtProtoError::Xrpc { error, .. } if error == "RecordNotFound"
)
}
}
#[allow(async_fn_in_trait)]
pub trait TokenSource: Send + Sync {
async fn access_token(&self) -> Result<String>;
}
#[derive(Clone)]
pub enum Auth {
Session(SessionAuth),
Oauth(OauthPlaceholder),
}
impl Auth {
pub fn bearer(&self) -> Result<&str> {
match self {
Auth::Session(s) => Ok(&s.access_jwt),
Auth::Oauth(_) => anyhow::bail!(
"the direct PdsClient does not carry OAuth tokens — atproto OAuth is \
handled by the @atproto/oauth-client sidecar (SidecarClient); \
use Auth::Session (app-password) for the direct-PDS path"
),
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct SessionAuth {
pub did: String,
#[serde(default)]
pub handle: Option<String>,
#[serde(rename = "accessJwt")]
pub access_jwt: String,
#[serde(rename = "refreshJwt", default)]
pub refresh_jwt: Option<String>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct OauthPlaceholder {}
pub async fn resolve_handle(client: &Client, resolver_base: &str, handle: &str) -> Result<String> {
let url = format!(
"{}/xrpc/com.atproto.identity.resolveHandle?handle={}",
resolver_base.trim_end_matches('/'),
urlencode(handle)
);
#[derive(Deserialize)]
struct ResolveHandleOut {
did: String,
}
let resp = crate::net::guarded_get_no_privacy(client, &url, &[]).await?;
if !resp.status().is_success() {
let err = xrpc_error_from(resp).await;
if let AtProtoError::Xrpc { status, .. } = &err {
if *status == StatusCode::BAD_REQUEST || *status == StatusCode::NOT_FOUND {
return Err(AtProtoError::HandleResolution {
handle: handle.to_string(),
}
.into());
}
}
return Err(err.into());
}
let out: ResolveHandleOut = resp
.json()
.await
.context("parsing resolveHandle response")?;
Ok(out.did)
}
pub async fn resolve_did_to_pds(client: &Client, plc_directory: &str, did: &str) -> Result<String> {
let doc_url = if let Some(rest) = did.strip_prefix("did:web:") {
let host = rest.replace(':', "/");
format!("https://{host}/.well-known/did.json")
} else if did.starts_with("did:plc:") {
format!("{}/{}", plc_directory.trim_end_matches('/'), did)
} else {
return Err(AtProtoError::DidResolution {
did: did.to_string(),
reason: "unsupported DID method (only did:plc and did:web are handled)".to_string(),
}
.into());
};
let resp = crate::net::guarded_get_no_privacy(client, &doc_url, &[]).await?;
if !resp.status().is_success() {
return Err(AtProtoError::DidResolution {
did: did.to_string(),
reason: format!("DID document fetch returned {}", resp.status()),
}
.into());
}
let doc: DidDocument = resp.json().await.context("parsing DID document")?;
let endpoint = doc
.pds_endpoint()
.ok_or_else(|| AtProtoError::DidResolution {
did: did.to_string(),
reason: "DID document has no #atproto_pds service endpoint".to_string(),
})?;
crate::net::assert_public_target(&endpoint)
.await
.map_err(|e| AtProtoError::DidResolution {
did: did.to_string(),
reason: format!("PDS serviceEndpoint is not a public target: {e}"),
})?;
Ok(endpoint)
}
#[derive(Debug, Clone, Deserialize)]
pub struct DidDocument {
#[serde(default)]
pub id: String,
#[serde(default)]
pub service: Vec<DidService>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DidService {
pub id: String,
#[serde(rename = "type", default)]
pub r#type: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: String,
}
impl DidDocument {
pub fn pds_endpoint(&self) -> Option<String> {
self.service
.iter()
.find(|s| s.id.ends_with("#atproto_pds"))
.map(|s| s.service_endpoint.trim_end_matches('/').to_string())
}
}
pub async fn login_with_app_password(
client: &Client,
pds_base: &str,
identifier: &str,
app_password: &str,
) -> Result<SessionAuth> {
let url = format!(
"{}/xrpc/com.atproto.server.createSession",
pds_base.trim_end_matches('/')
);
let resp = client
.post(&url)
.json(&json!({ "identifier": identifier, "password": app_password }))
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let session: SessionAuth = resp
.json()
.await
.context("parsing createSession response")?;
Ok(session)
}
#[derive(Clone)]
pub struct PdsClient {
http: Client,
pds_base: Arc<str>,
did: Arc<str>,
auth: Auth,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RecordEntry {
pub uri: String,
#[serde(default)]
pub cid: Option<String>,
pub value: Value,
}
impl RecordEntry {
pub fn rkey(&self) -> Option<&str> {
self.uri.rsplit('/').next()
}
pub fn parse<T: DeserializeOwned>(&self) -> Result<T> {
serde_json::from_value(self.value.clone())
.with_context(|| format!("deserializing record {}", self.uri))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ListRecordsResponse {
#[serde(default)]
pub records: Vec<RecordEntry>,
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WriteResult {
pub uri: String,
#[serde(default)]
pub cid: Option<String>,
}
impl WriteResult {
pub fn rkey(&self) -> Option<&str> {
self.uri.rsplit('/').next()
}
pub fn into_rkey(self) -> String {
self.rkey().unwrap_or_default().to_string()
}
}
impl PdsClient {
pub fn new(
http: Client,
pds_base: impl Into<String>,
did: impl Into<String>,
auth: Auth,
) -> Self {
Self {
http,
pds_base: Arc::from(pds_base.into().trim_end_matches('/')),
did: Arc::from(did.into()),
auth,
}
}
pub async fn login(
http: Client,
handle: &str,
app_password: &str,
resolver_base: Option<&str>,
plc_directory: Option<&str>,
) -> Result<Self> {
let resolver = resolver_base.unwrap_or(DEFAULT_RESOLVER_HOST);
let plc = plc_directory.unwrap_or(DEFAULT_PLC_DIRECTORY);
let did = resolve_handle(&http, resolver, handle).await?;
let pds_base = resolve_did_to_pds(&http, plc, &did).await?;
let session = login_with_app_password(&http, &pds_base, &did, app_password).await?;
Ok(Self::new(
http,
pds_base,
session.did.clone(),
Auth::Session(session),
))
}
pub fn did(&self) -> &str {
&self.did
}
pub fn pds_base(&self) -> &str {
&self.pds_base
}
fn authed_headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let bearer = self.auth.bearer()?;
let mut value = HeaderValue::from_str(&format!("Bearer {bearer}"))
.context("building Authorization header")?;
value.set_sensitive(true);
headers.insert(AUTHORIZATION, value);
Ok(headers)
}
fn xrpc_url(&self, method: &str) -> String {
format!("{}/xrpc/{}", self.pds_base, method)
}
pub async fn list_records(
&self,
collection: &str,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListRecordsResponse> {
let mut url = format!(
"{}?repo={}&collection={}",
self.xrpc_url("com.atproto.repo.listRecords"),
urlencode(&self.did),
urlencode(collection),
);
if let Some(limit) = limit {
url.push_str(&format!("&limit={limit}"));
}
if let Some(cursor) = cursor {
url.push_str(&format!("&cursor={}", urlencode(cursor)));
}
let mut req = self.http.get(&url);
if let Auth::Session(s) = &self.auth {
req = req.bearer_auth(&s.access_jwt);
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
resp.json().await.context("parsing listRecords response")
}
pub async fn list_all_records(&self, collection: &str) -> Result<Vec<RecordEntry>> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = self
.list_records(collection, Some(100), cursor.as_deref())
.await?;
let got = page.records.len();
out.extend(page.records);
match page.cursor {
Some(next) if got > 0 => cursor = Some(next),
_ => break,
}
}
Ok(out)
}
pub async fn create_record<T: Serialize>(
&self,
collection: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"record": record,
});
self.repo_write("com.atproto.repo.createRecord", body).await
}
pub async fn put_record<T: Serialize>(
&self,
collection: &str,
rkey: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"rkey": rkey,
"record": record,
});
self.repo_write("com.atproto.repo.putRecord", body).await
}
pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<()> {
let url = self.xrpc_url("com.atproto.repo.deleteRecord");
let body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"rkey": rkey,
});
let resp = self
.http
.post(&url)
.headers(self.authed_headers()?)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
Ok(())
}
pub async fn apply_writes(&self, writes: &[WriteOp]) -> Result<()> {
let url = self.xrpc_url("com.atproto.repo.applyWrites");
let ops: Vec<Value> = writes.iter().map(WriteOp::to_json).collect();
let body = json!({
"repo": self.did.as_ref(),
"writes": ops,
});
let resp = self
.http
.post(&url)
.headers(self.authed_headers()?)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
Ok(())
}
async fn repo_write(&self, method: &str, body: Value) -> Result<WriteResult> {
let url = self.xrpc_url(method);
let resp = self
.http
.post(&url)
.headers(self.authed_headers()?)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
resp.json()
.await
.with_context(|| format!("parsing {method} response"))
}
pub async fn list_subscriptions(&self) -> Result<Vec<(String, Subscription)>> {
self.list_typed(lexicon::nsid::SUBSCRIPTION).await
}
pub async fn create_subscription(&self, sub: &Subscription) -> Result<WriteResult> {
self.create_record(lexicon::nsid::SUBSCRIPTION, sub).await
}
pub async fn list_folders(&self) -> Result<Vec<(String, Folder)>> {
self.list_typed(lexicon::nsid::FOLDER).await
}
pub async fn create_folder(&self, folder: &Folder) -> Result<WriteResult> {
self.create_record(lexicon::nsid::FOLDER, folder).await
}
pub async fn list_saved(&self) -> Result<Vec<(String, Saved)>> {
self.list_typed(lexicon::nsid::SAVED).await
}
pub async fn create_saved(&self, saved: &Saved) -> Result<WriteResult> {
self.create_record(lexicon::nsid::SAVED, saved).await
}
pub async fn list_read_states(&self) -> Result<Vec<(String, ReadState)>> {
self.list_typed(lexicon::nsid::READ_STATE).await
}
pub async fn put_read_state(&self, rkey: &str, state: &ReadState) -> Result<WriteResult> {
self.put_record(lexicon::nsid::READ_STATE, rkey, state)
.await
}
pub async fn flush_read_states(&self, cursors: &[(String, ReadState, bool)]) -> Result<()> {
if cursors.is_empty() {
return Ok(());
}
let writes = read_state_write_ops(cursors)?;
self.apply_writes(&writes).await
}
async fn list_typed<T: DeserializeOwned>(&self, collection: &str) -> Result<Vec<(String, T)>> {
let records = self.list_all_records(collection).await?;
let mut out = Vec::with_capacity(records.len());
for rec in records {
let rkey = rec.rkey().unwrap_or_default().to_string();
match rec.parse::<T>() {
Ok(value) => out.push((rkey, value)),
Err(e) => tracing::warn!(
collection,
uri = %rec.uri,
error = %e,
"skipping unparseable record in collection"
),
}
}
Ok(out)
}
}
#[derive(Clone)]
pub struct SidecarClient {
http: Client,
public_url: Arc<str>,
internal_url: Arc<str>,
internal_secret: Arc<str>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SidecarSession {
pub did: String,
#[serde(default)]
pub handle: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RevokeResult {
#[serde(default)]
pub did: String,
#[serde(default)]
pub revoked: bool,
#[serde(default, rename = "hadSession")]
pub had_session: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoAction {
List,
Create,
Put,
Delete,
ApplyWrites,
}
impl RepoAction {
fn as_str(self) -> &'static str {
match self {
RepoAction::List => "list",
RepoAction::Create => "create",
RepoAction::Put => "put",
RepoAction::Delete => "delete",
RepoAction::ApplyWrites => "applyWrites",
}
}
}
#[derive(Debug, Deserialize)]
struct RepoOk {
#[serde(default)]
data: Value,
}
#[derive(Debug, Deserialize)]
struct RepoErr {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
status: Option<u16>,
}
impl SidecarClient {
pub fn new(
http: Client,
public_url: impl Into<String>,
internal_url: impl Into<String>,
internal_secret: impl Into<String>,
) -> Self {
Self {
http,
public_url: Arc::from(public_url.into().trim_end_matches('/')),
internal_url: Arc::from(internal_url.into().trim_end_matches('/')),
internal_secret: Arc::from(internal_secret.into()),
}
}
pub fn login_url(&self, handle: &str, return_to: Option<&str>) -> String {
let mut url = format!("{}/login?handle={}", self.public_url, urlencode(handle));
if let Some(r) = return_to {
url.push_str(&format!("&return={}", urlencode(r)));
}
url
}
pub async fn resolve_session(&self, session_id: &str) -> Result<Option<SidecarSession>> {
let url = format!(
"{}/internal/session/{}",
self.internal_url,
urlencode(session_id)
);
let resp = self
.http
.get(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.send()
.await?;
if resp.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let session: SidecarSession = resp
.json()
.await
.context("parsing /internal/session response")?;
Ok(Some(session))
}
pub async fn revoke_session(&self, did: &str) -> Result<RevokeResult> {
let url = format!("{}/internal/revoke", self.internal_url);
let resp = self
.http
.post(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.json(&json!({ "did": did }))
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let result: RevokeResult = resp
.json()
.await
.context("parsing /internal/revoke response")?;
Ok(result)
}
async fn repo(&self, body: Value) -> Result<Value> {
let url = format!("{}/internal/repo", self.internal_url);
let resp = self
.http
.post(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.json(&body)
.send()
.await?;
let status = resp.status();
if status.is_success() {
let ok: RepoOk = resp
.json()
.await
.context("parsing /internal/repo ok body")?;
return Ok(ok.data);
}
let err: RepoErr = resp.json().await.unwrap_or(RepoErr {
error: None,
message: None,
status: None,
});
let mapped = err
.status
.and_then(|s| StatusCode::from_u16(s).ok())
.unwrap_or(status);
Err(AtProtoError::Xrpc {
status: mapped,
error: err.error.unwrap_or_else(|| "Unknown".to_string()),
message: err.message,
}
.into())
}
pub async fn list_records(
&self,
did: &str,
collection: &str,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListRecordsResponse> {
let mut body = json!({
"did": did,
"action": RepoAction::List.as_str(),
"collection": collection,
});
if let Some(limit) = limit {
body["limit"] = json!(limit);
}
if let Some(cursor) = cursor {
body["cursor"] = json!(cursor);
}
let data = self.repo(body).await?;
serde_json::from_value(data).context("parsing sidecar listRecords data")
}
pub async fn list_all_records(&self, did: &str, collection: &str) -> Result<Vec<RecordEntry>> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = self
.list_records(did, collection, Some(100), cursor.as_deref())
.await?;
let got = page.records.len();
out.extend(page.records);
match page.cursor {
Some(next) if got > 0 => cursor = Some(next),
_ => break,
}
}
Ok(out)
}
pub async fn create_record<T: Serialize>(
&self,
did: &str,
collection: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"did": did,
"action": RepoAction::Create.as_str(),
"collection": collection,
"record": record,
});
let data = self.repo(body).await?;
serde_json::from_value(data).context("parsing sidecar createRecord data")
}
pub async fn put_record<T: Serialize>(
&self,
did: &str,
collection: &str,
rkey: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"did": did,
"action": RepoAction::Put.as_str(),
"collection": collection,
"rkey": rkey,
"record": record,
});
let data = self.repo(body).await?;
serde_json::from_value(data).context("parsing sidecar putRecord data")
}
pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<()> {
let body = json!({
"did": did,
"action": RepoAction::Delete.as_str(),
"collection": collection,
"rkey": rkey,
});
self.repo(body).await?;
Ok(())
}
pub async fn apply_writes(&self, did: &str, writes: &[WriteOp]) -> Result<()> {
if writes.is_empty() {
return Ok(());
}
let ops: Vec<Value> = writes.iter().map(WriteOp::to_sidecar_json).collect();
let body = json!({
"did": did,
"action": RepoAction::ApplyWrites.as_str(),
"writes": ops,
});
self.repo(body).await?;
Ok(())
}
pub async fn list_subscriptions(&self, did: &str) -> Result<Vec<(String, Subscription)>> {
self.list_typed(did, lexicon::nsid::SUBSCRIPTION).await
}
pub async fn create_subscription(&self, did: &str, sub: &Subscription) -> Result<WriteResult> {
self.create_record(did, lexicon::nsid::SUBSCRIPTION, sub)
.await
}
pub async fn delete_subscription(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::SUBSCRIPTION, rkey)
.await
}
pub async fn create_subscriptions_batch(&self, did: &str, subs: &[Subscription]) -> Result<()> {
let writes: Vec<WriteOp> = subs
.iter()
.map(|sub| {
Ok(WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: None,
value: serde_json::to_value(sub)?,
})
})
.collect::<Result<_>>()?;
self.apply_writes(did, &writes).await
}
pub async fn list_folders(&self, did: &str) -> Result<Vec<(String, Folder)>> {
self.list_typed(did, lexicon::nsid::FOLDER).await
}
pub async fn list_saved(&self, did: &str) -> Result<Vec<(String, Saved)>> {
self.list_typed(did, lexicon::nsid::SAVED).await
}
pub async fn list_read_states(&self, did: &str) -> Result<Vec<(String, ReadState)>> {
self.list_typed(did, lexicon::nsid::READ_STATE).await
}
pub async fn put_read_state(
&self,
did: &str,
rkey: &str,
state: &ReadState,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::READ_STATE, rkey, state)
.await
}
pub async fn flush_read_states(
&self,
did: &str,
cursors: &[(String, ReadState, bool)],
) -> Result<()> {
if cursors.is_empty() {
return Ok(());
}
let writes = read_state_write_ops(cursors)?;
self.apply_writes(did, &writes).await
}
pub async fn add_subscription(&self, did: &str, sub: &Subscription) -> Result<String> {
Ok(self.create_subscription(did, sub).await?.into_rkey())
}
pub async fn remove_subscription(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_subscription(did, rkey).await
}
pub async fn update_subscription(
&self,
did: &str,
rkey: &str,
sub: &Subscription,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::SUBSCRIPTION, rkey, sub)
.await
}
pub async fn list_subscriptions_sorted(
&self,
did: &str,
) -> Result<Vec<(String, Subscription)>> {
let mut subs = self.list_subscriptions(did).await?;
subs.sort_by(|(a_key, a), (b_key, b)| {
let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
a_title
.cmp(&b_title)
.then_with(|| a.url.cmp(&b.url))
.then_with(|| a_key.cmp(b_key))
});
Ok(subs)
}
pub async fn add_subscriptions_bulk(
&self,
did: &str,
subs: &[Subscription],
) -> Result<Vec<String>> {
let mut gen = TidGenerator::new();
let mut rkeys = Vec::with_capacity(subs.len());
let mut writes = Vec::with_capacity(subs.len());
for sub in subs {
let rkey = gen.next();
writes.push(WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: Some(rkey.clone()),
value: serde_json::to_value(sub)?,
});
rkeys.push(rkey);
}
self.apply_writes(did, &writes).await?;
Ok(rkeys)
}
pub async fn add_folder(&self, did: &str, folder: &Folder) -> Result<String> {
Ok(self
.create_record(did, lexicon::nsid::FOLDER, folder)
.await?
.into_rkey())
}
pub async fn remove_folder(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::FOLDER, rkey).await
}
pub async fn rename_folder(
&self,
did: &str,
rkey: &str,
folder: &Folder,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::FOLDER, rkey, folder)
.await
}
pub async fn list_folders_sorted(&self, did: &str) -> Result<Vec<(String, Folder)>> {
let mut folders = self.list_folders(did).await?;
folders.sort_by(|(a_key, a), (b_key, b)| {
a.position
.unwrap_or(u64::MAX)
.cmp(&b.position.unwrap_or(u64::MAX))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a_key.cmp(b_key))
});
Ok(folders)
}
pub async fn add_saved(&self, did: &str, saved: &Saved) -> Result<String> {
Ok(self
.create_record(did, lexicon::nsid::SAVED, saved)
.await?
.into_rkey())
}
pub async fn remove_saved(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::SAVED, rkey).await
}
pub async fn list_saved_sorted(&self, did: &str) -> Result<Vec<(String, Saved)>> {
let mut saved = self.list_saved(did).await?;
saved.sort_by(|(a_key, a), (b_key, b)| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a_key.cmp(b_key))
});
Ok(saved)
}
async fn list_typed<T: DeserializeOwned>(
&self,
did: &str,
collection: &str,
) -> Result<Vec<(String, T)>> {
let records = self.list_all_records(did, collection).await?;
let mut out = Vec::with_capacity(records.len());
for rec in records {
let rkey = rec.rkey().unwrap_or_default().to_string();
match rec.parse::<T>() {
Ok(value) => out.push((rkey, value)),
Err(e) => tracing::warn!(
collection,
uri = %rec.uri,
error = %e,
"skipping unparseable record in collection"
),
}
}
Ok(out)
}
}
fn read_state_write_ops(cursors: &[(String, ReadState, bool)]) -> Result<Vec<WriteOp>> {
cursors
.iter()
.map(|(rkey, state, pds_created)| {
let value = serde_json::to_value(state)?;
Ok(if *pds_created {
WriteOp::Update {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: rkey.clone(),
value,
}
} else {
WriteOp::Create {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: Some(rkey.clone()),
value,
}
})
})
.collect()
}
#[derive(Debug, Clone)]
pub enum WriteOp {
Create {
collection: String,
rkey: Option<String>,
value: Value,
},
Update {
collection: String,
rkey: String,
value: Value,
},
Delete {
collection: String,
rkey: String,
},
}
impl WriteOp {
fn to_json(&self) -> Value {
match self {
WriteOp::Create {
collection,
rkey,
value,
} => {
let mut op = json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": collection,
"value": value,
});
if let Some(rkey) = rkey {
op["rkey"] = json!(rkey);
}
op
}
WriteOp::Update {
collection,
rkey,
value,
} => json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": collection,
"rkey": rkey,
"value": value,
}),
WriteOp::Delete { collection, rkey } => json!({
"$type": "com.atproto.repo.applyWrites#delete",
"collection": collection,
"rkey": rkey,
}),
}
}
fn to_sidecar_json(&self) -> Value {
match self {
WriteOp::Create {
collection,
rkey,
value,
} => {
let mut op = json!({
"action": "create",
"collection": collection,
"value": value,
});
if let Some(rkey) = rkey {
op["rkey"] = json!(rkey);
}
op
}
WriteOp::Update {
collection,
rkey,
value,
} => json!({
"action": "update",
"collection": collection,
"rkey": rkey,
"value": value,
}),
WriteOp::Delete { collection, rkey } => json!({
"action": "delete",
"collection": collection,
"rkey": rkey,
}),
}
}
}
const S32_ALPHABET: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
struct TidGenerator {
last: u64,
clock_id: u64,
}
impl TidGenerator {
fn new() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
Self {
last: 0,
clock_id: nanos & 0x3ff,
}
}
fn next(&mut self) -> String {
let micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
let mut raw = ((micros & 0x001f_ffff_ffff_ffff) << 10) | self.clock_id;
if raw <= self.last {
raw = self.last + 1;
}
self.last = raw;
encode_s32_tid(raw)
}
}
fn encode_s32_tid(mut v: u64) -> String {
let mut buf = [0u8; 13];
for slot in buf.iter_mut().rev() {
*slot = S32_ALPHABET[(v & 0x1f) as usize];
v >>= 5;
}
String::from_utf8(buf.to_vec()).unwrap_or_default()
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[derive(Debug, Deserialize)]
struct XrpcErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
}
async fn xrpc_error_from(resp: reqwest::Response) -> AtProtoError {
let status = resp.status();
let (error, message) = match resp.json::<XrpcErrorBody>().await {
Ok(body) => (
body.error.unwrap_or_else(|| "Unknown".to_string()),
body.message,
),
Err(_) => ("Unknown".to_string(), None),
};
AtProtoError::Xrpc {
status,
error,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn subscription_list_json() -> Value {
json!({
"records": [
{
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0001",
"cid": "bafyreisubone",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"title": "Example Blog",
"siteUrl": "https://example.com/",
"fetchHint": "hourly",
"createdAt": "2026-07-12T00:00:00.000Z"
}
},
{
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0002",
"cid": "bafyreisubtwo",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": "https://blog.example.org/atom.xml",
"createdAt": "2026-07-11T12:00:00.000Z"
}
}
],
"cursor": "3ksub0002"
})
}
#[test]
fn list_records_envelope_deserializes() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
assert_eq!(resp.records.len(), 2);
assert_eq!(resp.cursor.as_deref(), Some("3ksub0002"));
assert_eq!(resp.records[0].cid.as_deref(), Some("bafyreisubone"));
}
#[test]
fn record_entry_rkey_is_last_uri_segment() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
assert_eq!(resp.records[0].rkey(), Some("3ksub0001"));
assert_eq!(resp.records[1].rkey(), Some("3ksub0002"));
}
#[test]
fn record_value_parses_into_lexicon_subscription() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
let full: Subscription = resp.records[0].parse().expect("parse full sub");
assert_eq!(full.r#type, lexicon::nsid::SUBSCRIPTION);
assert_eq!(full.url, "https://example.com/feed.xml");
assert_eq!(full.title.as_deref(), Some("Example Blog"));
assert_eq!(full.site_url.as_deref(), Some("https://example.com/"));
assert_eq!(full.fetch_hint, Some(lexicon::FetchHint::Hourly));
let minimal: Subscription = resp.records[1].parse().expect("parse minimal sub");
assert_eq!(minimal.url, "https://blog.example.org/atom.xml");
assert!(minimal.title.is_none());
}
fn ssrf_test_client() -> Client {
Client::builder()
.user_agent(crate::USER_AGENT)
.build()
.unwrap()
}
#[tokio::test]
async fn resolve_did_web_blocks_metadata_host() {
let client = ssrf_test_client();
let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:169.254.169.254")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn resolve_did_web_blocks_loopback_host() {
let client = ssrf_test_client();
let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:127.0.0.1")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn resolve_handle_blocks_metadata_resolver_base() {
let client = ssrf_test_client();
let err = resolve_handle(&client, "http://169.254.169.254", "alice.example.com")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn service_endpoint_internal_target_rejected() {
assert!(crate::net::assert_public_target("http://169.254.169.254/")
.await
.is_err());
assert!(crate::net::assert_public_target("http://127.0.0.1:3000/")
.await
.is_err());
assert!(crate::net::assert_public_target("https://1.1.1.1/")
.await
.is_ok());
}
#[test]
fn write_result_deserializes() {
let wr: WriteResult = serde_json::from_value(json!({
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
"cid": "bafyreinew"
}))
.expect("write result");
assert!(wr.uri.ends_with("3ksubnew"));
assert_eq!(wr.cid.as_deref(), Some("bafyreinew"));
}
#[test]
fn did_document_finds_pds_endpoint() {
let doc: DidDocument = serde_json::from_value(json!({
"id": "did:plc:abc123",
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com/"
}
]
}))
.expect("did doc");
assert_eq!(
doc.pds_endpoint().as_deref(),
Some("https://pds.example.com")
);
}
#[test]
fn did_document_without_pds_yields_none() {
let doc: DidDocument = serde_json::from_value(json!({
"id": "did:plc:abc123",
"service": []
}))
.expect("did doc");
assert!(doc.pds_endpoint().is_none());
}
#[test]
fn session_auth_deserializes_create_session_shape() {
let session: SessionAuth = serde_json::from_value(json!({
"did": "did:plc:abc123",
"handle": "alice.example.com",
"accessJwt": "eyJh...access",
"refreshJwt": "eyJh...refresh"
}))
.expect("session");
assert_eq!(session.did, "did:plc:abc123");
assert_eq!(session.handle.as_deref(), Some("alice.example.com"));
let auth = Auth::Session(session);
assert_eq!(auth.bearer().expect("bearer"), "eyJh...access");
}
#[test]
fn oauth_variant_carries_no_direct_bearer() {
let auth = Auth::Oauth(OauthPlaceholder::default());
assert!(
auth.bearer().is_err(),
"Auth::Oauth carries no direct bearer — the sidecar owns the OAuth path"
);
}
#[test]
fn apply_writes_ops_render_tagged_union() {
let create = WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: None,
value: json!({"url": "https://example.com/feed.xml"}),
};
let update = WriteOp::Update {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: "feedhash01".to_string(),
value: json!({"feedUrl": "https://example.com/feed.xml"}),
};
let delete = WriteOp::Delete {
collection: lexicon::nsid::SAVED.to_string(),
rkey: "3ksaved01".to_string(),
};
assert_eq!(
create.to_json()["$type"],
json!("com.atproto.repo.applyWrites#create")
);
assert!(create.to_json().get("rkey").is_none());
assert_eq!(
update.to_json()["$type"],
json!("com.atproto.repo.applyWrites#update")
);
assert_eq!(update.to_json()["rkey"], json!("feedhash01"));
assert_eq!(
delete.to_json()["$type"],
json!("com.atproto.repo.applyWrites#delete")
);
assert_eq!(delete.to_json()["rkey"], json!("3ksaved01"));
}
#[test]
fn read_state_flush_creates_first_then_updates() {
let fresh = (
"rs-fresh".to_string(),
ReadState::new("https://a.example/feed.xml", None, "2026-07-12T00:00:00Z"),
false,
);
let existing = (
"rs-existing".to_string(),
ReadState::new(
"https://b.example/feed.xml",
Some("2026-07-11T00:00:00Z".to_string()),
"2026-07-12T00:00:00Z",
),
true,
);
let ops = read_state_write_ops(&[fresh, existing]).expect("build ops");
assert_eq!(ops.len(), 2);
let create = ops[0].to_json();
assert_eq!(
create["$type"],
json!("com.atproto.repo.applyWrites#create"),
"first flush of a new feed must CREATE its readState record"
);
assert_eq!(create["rkey"], json!("rs-fresh"));
assert!(create["value"].get("readThrough").is_none());
let update = ops[1].to_json();
assert_eq!(
update["$type"],
json!("com.atproto.repo.applyWrites#update")
);
assert_eq!(update["rkey"], json!("rs-existing"));
assert_eq!(ops.len(), 2);
}
#[test]
fn urlencode_escapes_did_colons_and_keeps_unreserved() {
assert_eq!(urlencode("did:plc:abc123"), "did%3Aplc%3Aabc123");
assert_eq!(
urlencode("community.lexicon.rss.subscription"),
"community.lexicon.rss.subscription"
);
assert_eq!(urlencode("a b&c"), "a%20b%26c");
}
#[test]
fn xrpc_record_not_found_is_detected() {
let err = AtProtoError::Xrpc {
status: StatusCode::BAD_REQUEST,
error: "RecordNotFound".to_string(),
message: Some("Could not locate record".to_string()),
};
assert!(err.is_record_not_found());
}
#[test]
fn write_result_extracts_rkey_from_uri() {
let wr: WriteResult = serde_json::from_value(json!({
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
"cid": "bafyreinew"
}))
.expect("write result");
assert_eq!(wr.rkey(), Some("3ksubnew"));
assert_eq!(wr.into_rkey(), "3ksubnew");
}
fn parse_all<T: DeserializeOwned>(v: Value) -> Vec<(String, T)> {
let resp: ListRecordsResponse = serde_json::from_value(v).expect("envelope");
resp.records
.into_iter()
.map(|r| {
let rkey = r.rkey().unwrap_or_default().to_string();
(rkey, r.parse::<T>().expect("parse"))
})
.collect()
}
fn sort_subscriptions(subs: &mut [(String, Subscription)]) {
subs.sort_by(|(a_key, a), (b_key, b)| {
let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
a_title
.cmp(&b_title)
.then_with(|| a.url.cmp(&b.url))
.then_with(|| a_key.cmp(b_key))
});
}
#[test]
fn subscriptions_sort_by_title_then_url_then_rkey() {
let mut subs: Vec<(String, Subscription)> = parse_all(json!({
"records": [
{
"uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-zebra",
"value": { "url": "https://z.example/feed", "title": "Zebra News",
"createdAt": "2026-07-12T00:00:00.000Z" }
},
{
"uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-untitled",
"value": { "url": "https://aaa.example/feed",
"createdAt": "2026-07-12T00:00:00.000Z" }
},
{
"uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-apple",
"value": { "url": "https://apple.example/feed", "title": "apple blog",
"createdAt": "2026-07-12T00:00:00.000Z" }
}
]
}));
sort_subscriptions(&mut subs);
let order: Vec<&str> = subs.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(order, vec!["rk-apple", "rk-untitled", "rk-zebra"]);
}
#[test]
fn folders_sort_by_position_then_name_then_rkey() {
let mut folders: Vec<(String, Folder)> = parse_all(json!({
"records": [
{
"uri": "at://did:plc:x/community.lexicon.rss.folder/rk-nopos",
"value": { "name": "Aardvark", "createdAt": "2026-07-12T00:00:00.000Z" }
},
{
"uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos2",
"value": { "name": "Tech", "position": 2, "createdAt": "2026-07-12T00:00:00.000Z" }
},
{
"uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos0",
"value": { "name": "News", "position": 0, "createdAt": "2026-07-12T00:00:00.000Z" }
}
]
}));
folders.sort_by(|(a_key, a), (b_key, b)| {
a.position
.unwrap_or(u64::MAX)
.cmp(&b.position.unwrap_or(u64::MAX))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a_key.cmp(b_key))
});
let order: Vec<&str> = folders.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(order, vec!["rk-pos0", "rk-pos2", "rk-nopos"]);
}
#[test]
fn saved_sort_newest_first_by_created_at() {
let mut saved: Vec<(String, Saved)> = parse_all(json!({
"records": [
{
"uri": "at://did:plc:x/community.lexicon.rss.saved/rk-old",
"value": { "url": "https://e.example/1", "createdAt": "2026-07-10T00:00:00.000Z" }
},
{
"uri": "at://did:plc:x/community.lexicon.rss.saved/rk-new",
"value": { "url": "https://e.example/2", "createdAt": "2026-07-12T00:00:00.000Z" }
}
]
}));
saved.sort_by(|(a_key, a), (b_key, b)| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a_key.cmp(b_key))
});
assert_eq!(saved[0].0, "rk-new");
assert_eq!(saved[1].0, "rk-old");
}
#[test]
fn bulk_subscription_creates_render_sidecar_applywrites_ops() {
let subs = [
Subscription::new("https://a.example/feed", "2026-07-12T00:00:00.000Z"),
Subscription::new("https://b.example/feed", "2026-07-12T00:00:00.000Z"),
];
let mut gen = TidGenerator::new();
let ops: Vec<Value> = subs
.iter()
.map(|sub| {
WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: Some(gen.next()),
value: serde_json::to_value(sub).expect("value"),
}
.to_sidecar_json()
})
.collect();
assert_eq!(ops.len(), 2);
for op in &ops {
assert_eq!(op["action"], json!("create"));
assert_eq!(
op["collection"],
json!("community.lexicon.rss.subscription")
);
assert!(op["rkey"].is_string(), "bulk import pins client-side rkeys");
assert_eq!(
op["value"]["$type"],
json!("community.lexicon.rss.subscription")
);
}
let k0 = ops[0]["rkey"].as_str().unwrap();
let k1 = ops[1]["rkey"].as_str().unwrap();
assert!(k0 < k1, "bulk rkeys must sort in input order ({k0} < {k1})");
}
#[test]
fn tid_rkeys_are_13_char_s32_and_monotonic() {
let mut gen = TidGenerator::new();
let mut prev: Option<String> = None;
for _ in 0..1000 {
let tid = gen.next();
assert_eq!(tid.len(), 13, "a TID is 13 s32 chars");
assert!(
tid.bytes().all(|b| S32_ALPHABET.contains(&b)),
"TID {tid} uses only the s32 alphabet"
);
if let Some(p) = &prev {
assert!(*p < tid, "TIDs must be strictly increasing ({p} < {tid})");
}
prev = Some(tid);
}
}
#[test]
fn tid_rkeys_are_valid_atproto_record_keys() {
let mut gen = TidGenerator::new();
let tid = gen.next();
assert!(!tid.is_empty() && tid.len() <= 512);
assert!(tid != "." && tid != "..");
assert!(tid
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b':' | b'-')));
}
#[test]
fn s32_encoding_is_ascending_for_ascending_values() {
assert!(encode_s32_tid(1) < encode_s32_tid(2));
assert!(encode_s32_tid(31) < encode_s32_tid(32));
assert!(encode_s32_tid(1_000_000) < encode_s32_tid(1_000_001));
let max_tid = (0x001f_ffff_ffff_ffffu64 << 10) | 0x3ff;
assert!(encode_s32_tid(max_tid - 1) < encode_s32_tid(max_tid));
}
}