feather_reader/atproto.rs
1//! The atproto identity + PDS record layer.
2//!
3//! FeatherReader's defining bet is that a user's feed
4//! subscriptions, folders, saved items, and batched read-state live as records
5//! in the user's **own** atproto PDS under the open `community.lexicon.rss.*`
6//! community lexicon — not in the app's database. This module is the client that
7//! reads and writes those records.
8//!
9//! It has three layers:
10//!
11//! 1. **Identity resolution** ([`resolve_handle`], [`resolve_did_to_pds`]) —
12//! turn a handle (`alice.example.com`) into a DID (`did:plc:…`), then resolve
13//! the DID document to the PDS service endpoint. Handles resolve via the
14//! account's PDS `com.atproto.identity.resolveHandle` (or the well-known
15//! `/.well-known/atproto-did`); DIDs resolve via the PLC directory
16//! (`did:plc:*`) or the `did:web` well-known document.
17//! 2. **A lightweight [`PdsClient`]** — holds the resolved DID, the PDS base URL,
18//! and an [`Auth`] token, and exposes typed calls over `com.atproto.repo.*`:
19//! [`list_records`](PdsClient::list_records),
20//! [`create_record`](PdsClient::create_record),
21//! [`put_record`](PdsClient::put_record),
22//! [`delete_record`](PdsClient::delete_record), and
23//! [`apply_writes`](PdsClient::apply_writes) (the **batch** call the
24//! read-state flusher uses to coalesce many per-feed cursor writes into one
25//! round-trip).
26//! 3. **Typed convenience wrappers** wired to the [`crate::lexicon`] record
27//! types (list/create [`Subscription`]/[`Folder`]/[`Saved`], put
28//! [`ReadState`], batch-flush many `ReadState` cursors).
29//!
30//! ## Auth — the OAuth sidecar is the live path
31//!
32//! Auth is a **trait/enum boundary** so the mechanism can vary without touching
33//! call sites. There are two paths:
34//!
35//! * **The live path — the atproto OAuth confidential client, via [`SidecarClient`].**
36//! atproto OAuth (DPoP, PAR, token refresh) is fiddly and is **not** hand-rolled
37//! in Rust: it runs in a small, supported `@atproto/oauth-client-node` sidecar.
38//! The Rust server never holds
39//! PDS tokens — it POSTs every `com.atproto.repo.*` op to the sidecar's
40//! `/internal/repo` endpoint (gated by a shared `X-Internal-Secret`), and the
41//! sidecar restores the DID's OAuth session (transparent DPoP + token refresh)
42//! and runs the matching XRPC call. [`SidecarClient`] is that client; the typed
43//! convenience wrappers (list/create/put/delete subscriptions, batch-flush
44//! read-state) live on it and map 1:1 to the old [`PdsClient`] surface.
45//! * **The interim path — [`Auth::Session`] (app password).** A session obtained
46//! from `com.atproto.server.createSession`. Kept behind the [`Auth`] seam as a
47//! fallback for local runs without the sidecar, but it is **no longer the live
48//! path**: [`PdsClient`] and [`login_with_app_password`] remain for tests and
49//! dev, while [`SidecarClient`] is what the web layer routes through.
50//!
51//! All network I/O is `reqwest` (rustls, no OpenSSL); every fallible path returns
52//! [`anyhow::Result`] or the typed [`AtProtoError`] — nothing panics.
53
54use std::sync::Arc;
55
56use anyhow::{Context, Result};
57use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
58use reqwest::{Client, StatusCode};
59use serde::de::DeserializeOwned;
60use serde::{Deserialize, Serialize};
61use serde_json::{json, Value};
62
63use crate::lexicon::{self, Folder, ReadState, Saved, Subscription};
64
65/// The public PLC directory, used to resolve `did:plc:*` DIDs to their DID
66/// document (and thus their PDS service endpoint).
67pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
68
69/// The default appview/entryway used only as a bootstrap host for handle
70/// resolution when the caller has no PDS hint yet. Handle resolution ultimately
71/// works against any atproto host that implements
72/// `com.atproto.identity.resolveHandle`; `bsky.social` is a reliable default.
73pub const DEFAULT_RESOLVER_HOST: &str = "https://bsky.social";
74
75/// Errors from the atproto identity + PDS layer.
76///
77/// Wraps the transport, the atproto XRPC error envelope (`{"error","message"}`),
78/// and the identity-resolution failure modes so callers can distinguish "the
79/// network broke" from "the PDS said no" from "this handle doesn't resolve".
80#[derive(Debug, thiserror::Error)]
81pub enum AtProtoError {
82 /// The underlying HTTP transport failed (DNS, TLS, timeout, connect).
83 #[error("atproto transport error: {0}")]
84 Transport(#[from] reqwest::Error),
85
86 /// The XRPC endpoint returned a non-2xx status with an atproto error
87 /// envelope (or an opaque body). `error` is the atproto error name (e.g.
88 /// `RecordNotFound`, `AuthMissing`), `message` the human string.
89 #[error("atproto XRPC error {status}: {error}{}", .message.as_deref().map(|m| format!(" — {m}")).unwrap_or_default())]
90 Xrpc {
91 /// The HTTP status code.
92 status: StatusCode,
93 /// The atproto error name (the `error` field), or `"Unknown"`.
94 error: String,
95 /// The optional human-readable `message` field.
96 message: Option<String>,
97 },
98
99 /// A handle could not be resolved to a DID.
100 #[error("could not resolve handle {handle:?} to a DID")]
101 HandleResolution {
102 /// The handle that failed to resolve.
103 handle: String,
104 },
105
106 /// A DID document could not be resolved, or lacks a usable PDS service
107 /// endpoint (`#atproto_pds`).
108 #[error("could not resolve DID {did:?} to a PDS endpoint: {reason}")]
109 DidResolution {
110 /// The DID that failed to resolve.
111 did: String,
112 /// Why resolution failed.
113 reason: String,
114 },
115}
116
117impl AtProtoError {
118 /// True when the XRPC error is a "record not found" — handy for upsert paths
119 /// that treat a missing record as "create instead of update".
120 pub fn is_record_not_found(&self) -> bool {
121 matches!(
122 self,
123 AtProtoError::Xrpc { error, .. } if error == "RecordNotFound"
124 )
125 }
126}
127
128// ---------------------------------------------------------------------------
129// Auth — the direct-PDS path (dev / tests)
130// ---------------------------------------------------------------------------
131
132/// A source of atproto access tokens.
133///
134/// This trait abstracts over token acquisition for the direct [`PdsClient`]
135/// (used by local runs and tests). A [`PdsClient`] can hold a `dyn TokenSource`
136/// instead of a static [`Auth`] without any call-site change, so a token source
137/// that refreshes out of band can be dropped in later.
138///
139/// It is async + `Send + Sync` so a background refresh can live behind it.
140#[allow(async_fn_in_trait)]
141pub trait TokenSource: Send + Sync {
142 /// Return the current bearer access token to send as `Authorization`.
143 async fn access_token(&self) -> Result<String>;
144}
145
146/// The auth material a [`PdsClient`] carries.
147///
148/// A small enum rather than a bare string, so the match stays exhaustive if a
149/// second direct-auth mechanism is added alongside app-password sessions.
150#[derive(Clone)]
151pub enum Auth {
152 /// A bearer access token from a `com.atproto.server.createSession`
153 /// (app-password) session. This is the direct-PDS auth used by local runs
154 /// and tests; the live web path authenticates via the OAuth sidecar instead
155 /// (see [`SidecarClient`]).
156 Session(SessionAuth),
157
158 /// The atproto OAuth confidential-client path is handled entirely by the
159 /// `@atproto/oauth-client` sidecar ([`SidecarClient`]), which mints, DPoP-binds,
160 /// and refreshes tokens. The direct [`PdsClient`] does not carry OAuth tokens;
161 /// this variant is a placeholder so the `Auth` enum documents that the OAuth
162 /// path lives elsewhere.
163 Oauth(OauthPlaceholder),
164}
165
166impl Auth {
167 /// The bearer access token to present on `com.atproto.repo.*` calls.
168 ///
169 /// Only [`Auth::Session`] carries a token (the session's `accessJwt`).
170 /// [`Auth::Oauth`] carries none — the sidecar owns the OAuth path — so it
171 /// returns an error pointing callers at [`SidecarClient`].
172 pub fn bearer(&self) -> Result<&str> {
173 match self {
174 Auth::Session(s) => Ok(&s.access_jwt),
175 Auth::Oauth(_) => anyhow::bail!(
176 "the direct PdsClient does not carry OAuth tokens — atproto OAuth is \
177 handled by the @atproto/oauth-client sidecar (SidecarClient); \
178 use Auth::Session (app-password) for the direct-PDS path"
179 ),
180 }
181 }
182}
183
184/// A session obtained from `com.atproto.server.createSession` (interim
185/// app-password auth). Holds the DID + tokens + handle the server returned.
186#[derive(Clone, Debug, Deserialize)]
187pub struct SessionAuth {
188 /// The account DID this session authenticates.
189 pub did: String,
190 /// The account handle at session-creation time.
191 #[serde(default)]
192 pub handle: Option<String>,
193 /// The bearer access token presented on authed XRPC calls.
194 #[serde(rename = "accessJwt")]
195 pub access_jwt: String,
196 /// The refresh token, exchanged via `com.atproto.server.refreshSession`.
197 /// The direct-PDS refresh flow is not implemented here; the live web path
198 /// refreshes via the OAuth sidecar instead.
199 #[serde(rename = "refreshJwt", default)]
200 pub refresh_jwt: Option<String>,
201}
202
203/// Placeholder for the OAuth variant of [`Auth`].
204///
205/// Intentionally empty: the OAuth session material (DPoP key handle, token
206/// references) is held entirely by the sidecar, not by the direct [`PdsClient`].
207/// This type exists only so [`Auth::Oauth`] is a real variant and the split is
208/// visible in the type system.
209#[derive(Clone, Debug, Default)]
210#[non_exhaustive]
211pub struct OauthPlaceholder {}
212
213// ---------------------------------------------------------------------------
214// Identity resolution
215// ---------------------------------------------------------------------------
216
217/// Resolve an atproto handle to its DID.
218///
219/// Uses `com.atproto.identity.resolveHandle` against `resolver_base` (any host
220/// that implements it; [`DEFAULT_RESOLVER_HOST`] is a safe bootstrap). A fuller
221/// implementation would also try the DNS `_atproto` TXT record and the
222/// `https://<handle>/.well-known/atproto-did` fallback; the XRPC path is the
223/// common case and the one implemented here.
224pub async fn resolve_handle(client: &Client, resolver_base: &str, handle: &str) -> Result<String> {
225 // Build the query manually rather than via reqwest's `.query()` so we don't
226 // depend on the optional `query`/`url` reqwest feature (the declared feature
227 // set is rustls + gzip + json only).
228 let url = format!(
229 "{}/xrpc/com.atproto.identity.resolveHandle?handle={}",
230 resolver_base.trim_end_matches('/'),
231 urlencode(handle)
232 );
233
234 #[derive(Deserialize)]
235 struct ResolveHandleOut {
236 did: String,
237 }
238
239 // Route through the SSRF guard: `resolver_base` can be a user-influenced PDS
240 // host (from a prior DID-doc resolution), so a hostile endpoint must not be
241 // able to target loopback / link-local / metadata. Feed-privacy is NOT
242 // applied here (this is a legitimate atproto XRPC call, not a feed fetch).
243 let resp = crate::net::guarded_get_no_privacy(client, &url, &[]).await?;
244 if !resp.status().is_success() {
245 // Surface the XRPC envelope but map the common "not found" to the typed
246 // handle-resolution error so callers get a clean signal.
247 let err = xrpc_error_from(resp).await;
248 if let AtProtoError::Xrpc { status, .. } = &err {
249 if *status == StatusCode::BAD_REQUEST || *status == StatusCode::NOT_FOUND {
250 return Err(AtProtoError::HandleResolution {
251 handle: handle.to_string(),
252 }
253 .into());
254 }
255 }
256 return Err(err.into());
257 }
258
259 let out: ResolveHandleOut = resp
260 .json()
261 .await
262 .context("parsing resolveHandle response")?;
263 Ok(out.did)
264}
265
266/// Resolve a DID to its PDS service endpoint by fetching + parsing its DID
267/// document.
268///
269/// * `did:plc:*` → the PLC directory (`{plc_directory}/{did}`).
270/// * `did:web:host` → `https://host/.well-known/did.json`.
271///
272/// The PDS endpoint is the service in the DID doc whose `id` ends with
273/// `#atproto_pds` (type `AtprotoPersonalDataServer`); its `serviceEndpoint` is
274/// the base URL for all `com.atproto.repo.*` calls.
275pub async fn resolve_did_to_pds(client: &Client, plc_directory: &str, did: &str) -> Result<String> {
276 let doc_url = if let Some(rest) = did.strip_prefix("did:web:") {
277 // did:web host may itself be percent-encoded / contain a path; the
278 // common case is a bare host.
279 let host = rest.replace(':', "/");
280 format!("https://{host}/.well-known/did.json")
281 } else if did.starts_with("did:plc:") {
282 format!("{}/{}", plc_directory.trim_end_matches('/'), did)
283 } else {
284 return Err(AtProtoError::DidResolution {
285 did: did.to_string(),
286 reason: "unsupported DID method (only did:plc and did:web are handled)".to_string(),
287 }
288 .into());
289 };
290
291 // SSRF guard: `doc_url` is attacker-controllable for `did:web:<host>` (the
292 // host comes straight from the DID) — a hostile `did:web:169.254.169.254`
293 // or `did:web:localhost` would otherwise make the server fetch an internal
294 // target and reflect its body. Route through the IP/scheme guard (no
295 // feed-privacy layer — this is a DID document, not a feed).
296 let resp = crate::net::guarded_get_no_privacy(client, &doc_url, &[]).await?;
297 if !resp.status().is_success() {
298 return Err(AtProtoError::DidResolution {
299 did: did.to_string(),
300 reason: format!("DID document fetch returned {}", resp.status()),
301 }
302 .into());
303 }
304
305 let doc: DidDocument = resp.json().await.context("parsing DID document")?;
306 let endpoint = doc
307 .pds_endpoint()
308 .ok_or_else(|| AtProtoError::DidResolution {
309 did: did.to_string(),
310 reason: "DID document has no #atproto_pds service endpoint".to_string(),
311 })?;
312
313 // SSRF guard on the RESOLVED endpoint: the `serviceEndpoint` is fully
314 // attacker-controlled (it's whatever the DID document says) and is handed to
315 // XRPC clients that fetch it directly. Reject a private/loopback/metadata
316 // target here so a hostile DID doc can't point the PDS at an internal host.
317 crate::net::assert_public_target(&endpoint)
318 .await
319 .map_err(|e| AtProtoError::DidResolution {
320 did: did.to_string(),
321 reason: format!("PDS serviceEndpoint is not a public target: {e}"),
322 })?;
323 Ok(endpoint)
324}
325
326/// The subset of a DID document FeatherReader needs: its services, so it can
327/// find the `#atproto_pds` endpoint.
328#[derive(Debug, Clone, Deserialize)]
329pub struct DidDocument {
330 /// The document subject (the DID itself).
331 #[serde(default)]
332 pub id: String,
333 /// The declared services; the PDS is the one whose `id` ends `#atproto_pds`.
334 #[serde(default)]
335 pub service: Vec<DidService>,
336}
337
338/// One service entry in a [`DidDocument`].
339#[derive(Debug, Clone, Deserialize)]
340pub struct DidService {
341 /// The service id fragment (e.g. `#atproto_pds`).
342 pub id: String,
343 /// The service type (e.g. `AtprotoPersonalDataServer`).
344 #[serde(rename = "type", default)]
345 pub r#type: String,
346 /// The service base URL.
347 #[serde(rename = "serviceEndpoint")]
348 pub service_endpoint: String,
349}
350
351impl DidDocument {
352 /// The `#atproto_pds` service endpoint, if present.
353 pub fn pds_endpoint(&self) -> Option<String> {
354 self.service
355 .iter()
356 .find(|s| s.id.ends_with("#atproto_pds"))
357 .map(|s| s.service_endpoint.trim_end_matches('/').to_string())
358 }
359}
360
361// ---------------------------------------------------------------------------
362// Direct-PDS auth: app-password session
363// ---------------------------------------------------------------------------
364
365/// Create a session with an **app password** via
366/// `com.atproto.server.createSession`.
367///
368/// This is the direct-PDS path that makes [`PdsClient`] usable without the OAuth
369/// sidecar (local runs and tests). `pds_base` is the account's PDS (resolve it
370/// first with [`resolve_handle`] + [`resolve_did_to_pds`], or pass the entryway
371/// like `https://bsky.social`, which will service-proxy). `identifier` is a
372/// handle or DID; `app_password` is an app-password (never the main password).
373pub async fn login_with_app_password(
374 client: &Client,
375 pds_base: &str,
376 identifier: &str,
377 app_password: &str,
378) -> Result<SessionAuth> {
379 let url = format!(
380 "{}/xrpc/com.atproto.server.createSession",
381 pds_base.trim_end_matches('/')
382 );
383 let resp = client
384 .post(&url)
385 .json(&json!({ "identifier": identifier, "password": app_password }))
386 .send()
387 .await?;
388 if !resp.status().is_success() {
389 return Err(xrpc_error_from(resp).await.into());
390 }
391 let session: SessionAuth = resp
392 .json()
393 .await
394 .context("parsing createSession response")?;
395 Ok(session)
396}
397
398// ---------------------------------------------------------------------------
399// The PDS client
400// ---------------------------------------------------------------------------
401
402/// A lightweight client for one user's PDS repo.
403///
404/// Holds the user's DID (the repo to read/write), the PDS base URL (resolved
405/// from the DID doc), the shared [`reqwest::Client`], and the [`Auth`] token.
406/// All the `com.atproto.repo.*` methods below act on `self.did`'s repo.
407///
408/// Cheap to clone (`Arc` internals); one is held per logged-in session.
409#[derive(Clone)]
410pub struct PdsClient {
411 http: Client,
412 /// The PDS base URL, e.g. `https://pds.example.com` (no trailing slash).
413 pds_base: Arc<str>,
414 /// The repo DID all calls target.
415 did: Arc<str>,
416 /// The auth material (an app-password session bearer for the direct path).
417 auth: Auth,
418}
419
420/// A single record as returned in a `listRecords` / `getRecord` response.
421///
422/// `value` is the raw record body (with its `$type`); typed wrappers
423/// deserialize it into the matching [`crate::lexicon`] struct.
424#[derive(Debug, Clone, Deserialize)]
425pub struct RecordEntry {
426 /// The `at://did/collection/rkey` strong ref to this record.
427 pub uri: String,
428 /// The record CID (content hash).
429 #[serde(default)]
430 pub cid: Option<String>,
431 /// The raw record body.
432 pub value: Value,
433}
434
435impl RecordEntry {
436 /// The record key (the last `/`-segment of the `at://` URI).
437 pub fn rkey(&self) -> Option<&str> {
438 self.uri.rsplit('/').next()
439 }
440
441 /// Deserialize this record's `value` into a typed lexicon record.
442 pub fn parse<T: DeserializeOwned>(&self) -> Result<T> {
443 serde_json::from_value(self.value.clone())
444 .with_context(|| format!("deserializing record {}", self.uri))
445 }
446}
447
448/// The `com.atproto.repo.listRecords` response envelope.
449#[derive(Debug, Clone, Deserialize)]
450pub struct ListRecordsResponse {
451 /// The page of records.
452 #[serde(default)]
453 pub records: Vec<RecordEntry>,
454 /// The opaque pagination cursor for the next page, if any.
455 #[serde(default)]
456 pub cursor: Option<String>,
457}
458
459/// The `com.atproto.repo.createRecord` / `putRecord` response (a strong ref to
460/// the written record).
461#[derive(Debug, Clone, Deserialize)]
462pub struct WriteResult {
463 /// The `at://` URI of the written record.
464 pub uri: String,
465 /// The record CID after the write.
466 #[serde(default)]
467 pub cid: Option<String>,
468}
469
470impl WriteResult {
471 /// The record key — the last `/`-segment of the `at://` URI.
472 ///
473 /// The reader-facing `add_*` wrappers return this so the web layer can
474 /// address the freshly-created record (delete/rename) without a re-list.
475 pub fn rkey(&self) -> Option<&str> {
476 self.uri.rsplit('/').next()
477 }
478
479 /// The record key as an owned `String`, or the empty string if the URI is
480 /// somehow segment-less (never in practice — a PDS always returns an
481 /// `at://did/collection/rkey`). Convenience for the `-> rkey` wrappers.
482 pub fn into_rkey(self) -> String {
483 self.rkey().unwrap_or_default().to_string()
484 }
485}
486
487impl PdsClient {
488 /// Construct a client against an already-resolved PDS base + DID + auth.
489 pub fn new(
490 http: Client,
491 pds_base: impl Into<String>,
492 did: impl Into<String>,
493 auth: Auth,
494 ) -> Self {
495 Self {
496 http,
497 pds_base: Arc::from(pds_base.into().trim_end_matches('/')),
498 did: Arc::from(did.into()),
499 auth,
500 }
501 }
502
503 /// Resolve `handle` → DID → PDS, obtain an app-password session, and build a
504 /// ready-to-use client. A convenience constructor for the direct-PDS path
505 /// that exercises the whole stack end-to-end.
506 ///
507 /// `resolver_base` / `plc_directory` default to [`DEFAULT_RESOLVER_HOST`] /
508 /// [`DEFAULT_PLC_DIRECTORY`] when passed `None`.
509 pub async fn login(
510 http: Client,
511 handle: &str,
512 app_password: &str,
513 resolver_base: Option<&str>,
514 plc_directory: Option<&str>,
515 ) -> Result<Self> {
516 let resolver = resolver_base.unwrap_or(DEFAULT_RESOLVER_HOST);
517 let plc = plc_directory.unwrap_or(DEFAULT_PLC_DIRECTORY);
518
519 let did = resolve_handle(&http, resolver, handle).await?;
520 let pds_base = resolve_did_to_pds(&http, plc, &did).await?;
521 let session = login_with_app_password(&http, &pds_base, &did, app_password).await?;
522
523 Ok(Self::new(
524 http,
525 pds_base,
526 session.did.clone(),
527 Auth::Session(session),
528 ))
529 }
530
531 /// The repo DID this client targets.
532 pub fn did(&self) -> &str {
533 &self.did
534 }
535
536 /// The PDS base URL this client talks to.
537 pub fn pds_base(&self) -> &str {
538 &self.pds_base
539 }
540
541 /// Build the `Authorization: Bearer …` + JSON headers for an authed call.
542 fn authed_headers(&self) -> Result<HeaderMap> {
543 let mut headers = HeaderMap::new();
544 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
545 let bearer = self.auth.bearer()?;
546 let mut value = HeaderValue::from_str(&format!("Bearer {bearer}"))
547 .context("building Authorization header")?;
548 value.set_sensitive(true);
549 headers.insert(AUTHORIZATION, value);
550 Ok(headers)
551 }
552
553 fn xrpc_url(&self, method: &str) -> String {
554 format!("{}/xrpc/{}", self.pds_base, method)
555 }
556
557 // -- com.atproto.repo.* --------------------------------------------------
558
559 /// `com.atproto.repo.listRecords` — one page of a collection's records.
560 ///
561 /// `cursor` continues a previous page; `limit` caps the page (atproto's max
562 /// is 100). Use [`list_all_records`](Self::list_all_records) to page fully.
563 pub async fn list_records(
564 &self,
565 collection: &str,
566 limit: Option<u32>,
567 cursor: Option<&str>,
568 ) -> Result<ListRecordsResponse> {
569 // Build the query manually (see `resolve_handle`): no reqwest `query`
570 // feature dependency.
571 let mut url = format!(
572 "{}?repo={}&collection={}",
573 self.xrpc_url("com.atproto.repo.listRecords"),
574 urlencode(&self.did),
575 urlencode(collection),
576 );
577 if let Some(limit) = limit {
578 url.push_str(&format!("&limit={limit}"));
579 }
580 if let Some(cursor) = cursor {
581 url.push_str(&format!("&cursor={}", urlencode(cursor)));
582 }
583
584 // listRecords is public/unauthenticated on most PDSes, but we send the
585 // bearer when we have a session one so private repos work too.
586 let mut req = self.http.get(&url);
587 if let Auth::Session(s) = &self.auth {
588 req = req.bearer_auth(&s.access_jwt);
589 }
590 let resp = req.send().await?;
591 if !resp.status().is_success() {
592 return Err(xrpc_error_from(resp).await.into());
593 }
594 resp.json().await.context("parsing listRecords response")
595 }
596
597 /// Page through **all** records in a collection, following the cursor until
598 /// exhausted. Convenience over [`list_records`](Self::list_records) for the
599 /// login-time "load the whole follow-list" read.
600 pub async fn list_all_records(&self, collection: &str) -> Result<Vec<RecordEntry>> {
601 let mut out = Vec::new();
602 let mut cursor: Option<String> = None;
603 loop {
604 let page = self
605 .list_records(collection, Some(100), cursor.as_deref())
606 .await?;
607 let got = page.records.len();
608 out.extend(page.records);
609 match page.cursor {
610 // Guard against a PDS that echoes a cursor with an empty page.
611 Some(next) if got > 0 => cursor = Some(next),
612 _ => break,
613 }
614 }
615 Ok(out)
616 }
617
618 /// `com.atproto.repo.createRecord` — create a new record (server assigns the
619 /// rkey, `key: tid`). Returns the written record's strong ref.
620 pub async fn create_record<T: Serialize>(
621 &self,
622 collection: &str,
623 record: &T,
624 ) -> Result<WriteResult> {
625 let body = json!({
626 "repo": self.did.as_ref(),
627 "collection": collection,
628 "record": record,
629 });
630 self.repo_write("com.atproto.repo.createRecord", body).await
631 }
632
633 /// `com.atproto.repo.putRecord` — upsert a record at a **known** rkey
634 /// (`key: any`). This is the `readState` upsert primitive: a feed-derived
635 /// rkey makes the write idempotent (one record per feed).
636 pub async fn put_record<T: Serialize>(
637 &self,
638 collection: &str,
639 rkey: &str,
640 record: &T,
641 ) -> Result<WriteResult> {
642 let body = json!({
643 "repo": self.did.as_ref(),
644 "collection": collection,
645 "rkey": rkey,
646 "record": record,
647 });
648 self.repo_write("com.atproto.repo.putRecord", body).await
649 }
650
651 /// `com.atproto.repo.deleteRecord` — delete a record by collection + rkey
652 /// (e.g. unsubscribe → delete the subscription record).
653 pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<()> {
654 let url = self.xrpc_url("com.atproto.repo.deleteRecord");
655 let body = json!({
656 "repo": self.did.as_ref(),
657 "collection": collection,
658 "rkey": rkey,
659 });
660 let resp = self
661 .http
662 .post(&url)
663 .headers(self.authed_headers()?)
664 .json(&body)
665 .send()
666 .await?;
667 if !resp.status().is_success() {
668 return Err(xrpc_error_from(resp).await.into());
669 }
670 Ok(())
671 }
672
673 /// `com.atproto.repo.applyWrites` — a **batch** of create/update/delete
674 /// operations in one atomic-per-repo round-trip.
675 ///
676 /// This is the read-state flusher's workhorse: dozens of dirty per-feed
677 /// [`ReadState`] cursors coalesce into one call rather than one `putRecord`
678 /// each. See [`flush_read_states`](Self::flush_read_states).
679 pub async fn apply_writes(&self, writes: &[WriteOp]) -> Result<()> {
680 let url = self.xrpc_url("com.atproto.repo.applyWrites");
681 let ops: Vec<Value> = writes.iter().map(WriteOp::to_json).collect();
682 let body = json!({
683 "repo": self.did.as_ref(),
684 "writes": ops,
685 });
686 let resp = self
687 .http
688 .post(&url)
689 .headers(self.authed_headers()?)
690 .json(&body)
691 .send()
692 .await?;
693 if !resp.status().is_success() {
694 return Err(xrpc_error_from(resp).await.into());
695 }
696 Ok(())
697 }
698
699 /// Shared create/put path (both return a `{uri,cid}` strong ref).
700 async fn repo_write(&self, method: &str, body: Value) -> Result<WriteResult> {
701 let url = self.xrpc_url(method);
702 let resp = self
703 .http
704 .post(&url)
705 .headers(self.authed_headers()?)
706 .json(&body)
707 .send()
708 .await?;
709 if !resp.status().is_success() {
710 return Err(xrpc_error_from(resp).await.into());
711 }
712 resp.json()
713 .await
714 .with_context(|| format!("parsing {method} response"))
715 }
716
717 // -- typed lexicon wrappers ---------------------------------------------
718
719 /// List every [`Subscription`] record in the user's repo (paged fully). The
720 /// login-time "what does this user follow?" read.
721 pub async fn list_subscriptions(&self) -> Result<Vec<(String, Subscription)>> {
722 self.list_typed(lexicon::nsid::SUBSCRIPTION).await
723 }
724
725 /// Create a [`Subscription`] record (subscribe to a feed).
726 pub async fn create_subscription(&self, sub: &Subscription) -> Result<WriteResult> {
727 self.create_record(lexicon::nsid::SUBSCRIPTION, sub).await
728 }
729
730 /// List every [`Folder`] record in the user's repo.
731 pub async fn list_folders(&self) -> Result<Vec<(String, Folder)>> {
732 self.list_typed(lexicon::nsid::FOLDER).await
733 }
734
735 /// Create a [`Folder`] record.
736 pub async fn create_folder(&self, folder: &Folder) -> Result<WriteResult> {
737 self.create_record(lexicon::nsid::FOLDER, folder).await
738 }
739
740 /// List every [`Saved`] (starred) record in the user's repo.
741 pub async fn list_saved(&self) -> Result<Vec<(String, Saved)>> {
742 self.list_typed(lexicon::nsid::SAVED).await
743 }
744
745 /// Create a [`Saved`] record (star an article).
746 pub async fn create_saved(&self, saved: &Saved) -> Result<WriteResult> {
747 self.create_record(lexicon::nsid::SAVED, saved).await
748 }
749
750 /// List every [`ReadState`] cursor in the user's repo (the read side a
751 /// login-time read-state merge would consume).
752 pub async fn list_read_states(&self) -> Result<Vec<(String, ReadState)>> {
753 self.list_typed(lexicon::nsid::READ_STATE).await
754 }
755
756 /// Upsert a single [`ReadState`] cursor at its feed-derived rkey. For a
757 /// batch of dirty cursors prefer [`flush_read_states`](Self::flush_read_states).
758 pub async fn put_read_state(&self, rkey: &str, state: &ReadState) -> Result<WriteResult> {
759 self.put_record(lexicon::nsid::READ_STATE, rkey, state)
760 .await
761 }
762
763 /// Batch-flush many dirty [`ReadState`] cursors in one `applyWrites` call —
764 /// the debounced read-state flusher's coalesced write.
765 ///
766 /// Each `(rkey, state, pds_created)` becomes a `create` op at the feed-derived
767 /// rkey when the record does not yet exist, and an `update` when it does — so a
768 /// feed's FIRST flush succeeds (an `#update` on a missing record errors, and
769 /// `applyWrites` is atomic per-repo). Both kinds ride the same batch.
770 pub async fn flush_read_states(&self, cursors: &[(String, ReadState, bool)]) -> Result<()> {
771 if cursors.is_empty() {
772 return Ok(());
773 }
774 let writes = read_state_write_ops(cursors)?;
775 self.apply_writes(&writes).await
776 }
777
778 /// List a collection and parse each record's value into `T`, pairing it with
779 /// its rkey. Records that fail to deserialize are skipped with a warning
780 /// (forward-compat: a future writer's extra fields shouldn't break login).
781 async fn list_typed<T: DeserializeOwned>(&self, collection: &str) -> Result<Vec<(String, T)>> {
782 let records = self.list_all_records(collection).await?;
783 let mut out = Vec::with_capacity(records.len());
784 for rec in records {
785 let rkey = rec.rkey().unwrap_or_default().to_string();
786 match rec.parse::<T>() {
787 Ok(value) => out.push((rkey, value)),
788 Err(e) => tracing::warn!(
789 collection,
790 uri = %rec.uri,
791 error = %e,
792 "skipping unparseable record in collection"
793 ),
794 }
795 }
796 Ok(out)
797 }
798}
799
800// ---------------------------------------------------------------------------
801// The OAuth sidecar client — the LIVE com.atproto.repo.* path
802// ---------------------------------------------------------------------------
803
804/// A client for the atproto OAuth sidecar's **internal** API.
805///
806/// This is the live path for every authed repo operation. Rather than the Rust
807/// server holding PDS tokens, it POSTs `{did, action, …}` to the sidecar's
808/// `/internal/repo` endpoint (gated by the shared `X-Internal-Secret`); the
809/// sidecar `restore(did)`s the OAuth session — transparent DPoP + token refresh —
810/// and runs the matching XRPC call via `@atproto/api`. The `did` (plus the shared
811/// secret) is what authorizes the call; there is no bearer token on the Rust side.
812///
813/// It also fronts `/internal/session/:id`, the one-shot handoff the Rust callback
814/// uses to turn a `session_id` (from the sidecar's browser redirect) into the
815/// `{did, handle}` it keys its own signed cookie by.
816///
817/// Cheap to clone (shared `reqwest::Client` + `Arc`'d config).
818#[derive(Clone)]
819pub struct SidecarClient {
820 http: Client,
821 public_url: Arc<str>,
822 internal_url: Arc<str>,
823 internal_secret: Arc<str>,
824}
825
826/// The `{did, handle}` a session-id resolves to (the sidecar's
827/// `/internal/session/:id` body).
828#[derive(Debug, Clone, Deserialize)]
829pub struct SidecarSession {
830 /// The account DID that logged in.
831 pub did: String,
832 /// The account handle at login time.
833 #[serde(default)]
834 pub handle: Option<String>,
835}
836
837/// The sidecar's `/internal/revoke` response body:
838/// `{ ok:true, did, revoked, hadSession }`.
839#[derive(Debug, Clone, Deserialize)]
840pub struct RevokeResult {
841 /// The DID that was revoked.
842 #[serde(default)]
843 pub did: String,
844 /// Whether the OAuth token revocation at the PDS succeeded. `false` means
845 /// the local rows were still purged (best-effort), but the PDS-side tokens
846 /// may not have been invalidated (network failure).
847 #[serde(default)]
848 pub revoked: bool,
849 /// Whether the sidecar actually had a stored session for the DID.
850 #[serde(default, rename = "hadSession")]
851 pub had_session: bool,
852}
853
854/// The action verbs the sidecar's `/internal/repo` endpoint dispatches on.
855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
856pub enum RepoAction {
857 /// `com.atproto.repo.listRecords`.
858 List,
859 /// `com.atproto.repo.createRecord`.
860 Create,
861 /// `com.atproto.repo.putRecord`.
862 Put,
863 /// `com.atproto.repo.deleteRecord`.
864 Delete,
865 /// `com.atproto.repo.applyWrites` (batch).
866 ApplyWrites,
867}
868
869impl RepoAction {
870 fn as_str(self) -> &'static str {
871 match self {
872 RepoAction::List => "list",
873 RepoAction::Create => "create",
874 RepoAction::Put => "put",
875 RepoAction::Delete => "delete",
876 RepoAction::ApplyWrites => "applyWrites",
877 }
878 }
879}
880
881/// The `/internal/repo` success envelope: `{ ok:true, data:<raw XRPC JSON> }`.
882#[derive(Debug, Deserialize)]
883struct RepoOk {
884 #[serde(default)]
885 data: Value,
886}
887
888/// The `/internal/repo` error envelope: `{ ok:false, error, message, status? }`.
889#[derive(Debug, Deserialize)]
890struct RepoErr {
891 #[serde(default)]
892 error: Option<String>,
893 #[serde(default)]
894 message: Option<String>,
895 #[serde(default)]
896 status: Option<u16>,
897}
898
899impl SidecarClient {
900 /// Build a sidecar client from the shared [`reqwest::Client`] and the
901 /// resolved public + internal base URLs + internal secret (from
902 /// [`crate::config::SidecarConfig`]). `public_url` anchors the browser
903 /// `/login` redirect; `internal_url` is the loopback base for the `/internal/*`
904 /// API (they collapse to the same value in single-URL local dev).
905 pub fn new(
906 http: Client,
907 public_url: impl Into<String>,
908 internal_url: impl Into<String>,
909 internal_secret: impl Into<String>,
910 ) -> Self {
911 Self {
912 http,
913 public_url: Arc::from(public_url.into().trim_end_matches('/')),
914 internal_url: Arc::from(internal_url.into().trim_end_matches('/')),
915 internal_secret: Arc::from(internal_secret.into()),
916 }
917 }
918
919 /// The sidecar's public `/login` URL for a handle, round-tripping an opaque
920 /// `return` value through OAuth state (used to bounce the browser back to a
921 /// specific place after login). The browser is redirected here.
922 pub fn login_url(&self, handle: &str, return_to: Option<&str>) -> String {
923 let mut url = format!("{}/login?handle={}", self.public_url, urlencode(handle));
924 if let Some(r) = return_to {
925 url.push_str(&format!("&return={}", urlencode(r)));
926 }
927 url
928 }
929
930 /// Resolve a one-shot `session_id` (from the sidecar's post-OAuth redirect)
931 /// to the `{did, handle}` that logged in. `Ok(None)` on `404 SessionNotFound`.
932 pub async fn resolve_session(&self, session_id: &str) -> Result<Option<SidecarSession>> {
933 let url = format!(
934 "{}/internal/session/{}",
935 self.internal_url,
936 urlencode(session_id)
937 );
938 let resp = self
939 .http
940 .get(&url)
941 .header("X-Internal-Secret", self.internal_secret.as_ref())
942 .send()
943 .await?;
944 if resp.status() == StatusCode::NOT_FOUND {
945 return Ok(None);
946 }
947 if !resp.status().is_success() {
948 return Err(xrpc_error_from(resp).await.into());
949 }
950 let session: SidecarSession = resp
951 .json()
952 .await
953 .context("parsing /internal/session response")?;
954 Ok(Some(session))
955 }
956
957 /// Revoke a DID's OAuth session at the sidecar: `POST /internal/revoke`.
958 ///
959 /// This revokes the refresh + access tokens at the PDS **and** purges the
960 /// sidecar's stored `oauth_session` + `app_session` rows for the DID. It is
961 /// idempotent — revoking a DID with no live session returns
962 /// `had_session: false`. Called on `/logout` (so the cookie clear isn't the
963 /// only thing that ends the session) and on `/account/delete`.
964 pub async fn revoke_session(&self, did: &str) -> Result<RevokeResult> {
965 let url = format!("{}/internal/revoke", self.internal_url);
966 let resp = self
967 .http
968 .post(&url)
969 .header("X-Internal-Secret", self.internal_secret.as_ref())
970 .json(&json!({ "did": did }))
971 .send()
972 .await?;
973 if !resp.status().is_success() {
974 return Err(xrpc_error_from(resp).await.into());
975 }
976 let result: RevokeResult = resp
977 .json()
978 .await
979 .context("parsing /internal/revoke response")?;
980 Ok(result)
981 }
982
983 /// POST one op to `/internal/repo` and return the raw XRPC `data` payload.
984 ///
985 /// `body` must already carry `did` + `action` + the action's required fields
986 /// (the typed wrappers below build these). Maps the sidecar's error envelope
987 /// to [`AtProtoError`]: `404 SessionNotFound` → `Xrpc{error:"SessionNotFound"}`
988 /// so callers can treat it as "re-login required".
989 async fn repo(&self, body: Value) -> Result<Value> {
990 let url = format!("{}/internal/repo", self.internal_url);
991 let resp = self
992 .http
993 .post(&url)
994 .header("X-Internal-Secret", self.internal_secret.as_ref())
995 .json(&body)
996 .send()
997 .await?;
998 let status = resp.status();
999 if status.is_success() {
1000 let ok: RepoOk = resp
1001 .json()
1002 .await
1003 .context("parsing /internal/repo ok body")?;
1004 return Ok(ok.data);
1005 }
1006 // Error path: parse the sidecar's `{ok:false,error,message,status}` shape.
1007 let err: RepoErr = resp.json().await.unwrap_or(RepoErr {
1008 error: None,
1009 message: None,
1010 status: None,
1011 });
1012 let mapped = err
1013 .status
1014 .and_then(|s| StatusCode::from_u16(s).ok())
1015 .unwrap_or(status);
1016 Err(AtProtoError::Xrpc {
1017 status: mapped,
1018 error: err.error.unwrap_or_else(|| "Unknown".to_string()),
1019 message: err.message,
1020 }
1021 .into())
1022 }
1023
1024 // -- raw com.atproto.repo.* over the sidecar -----------------------------
1025
1026 /// `list` — one page of a collection's records for `did`.
1027 pub async fn list_records(
1028 &self,
1029 did: &str,
1030 collection: &str,
1031 limit: Option<u32>,
1032 cursor: Option<&str>,
1033 ) -> Result<ListRecordsResponse> {
1034 let mut body = json!({
1035 "did": did,
1036 "action": RepoAction::List.as_str(),
1037 "collection": collection,
1038 });
1039 if let Some(limit) = limit {
1040 body["limit"] = json!(limit);
1041 }
1042 if let Some(cursor) = cursor {
1043 body["cursor"] = json!(cursor);
1044 }
1045 let data = self.repo(body).await?;
1046 serde_json::from_value(data).context("parsing sidecar listRecords data")
1047 }
1048
1049 /// Page through **all** records in a collection for `did`.
1050 pub async fn list_all_records(&self, did: &str, collection: &str) -> Result<Vec<RecordEntry>> {
1051 let mut out = Vec::new();
1052 let mut cursor: Option<String> = None;
1053 loop {
1054 let page = self
1055 .list_records(did, collection, Some(100), cursor.as_deref())
1056 .await?;
1057 let got = page.records.len();
1058 out.extend(page.records);
1059 match page.cursor {
1060 Some(next) if got > 0 => cursor = Some(next),
1061 _ => break,
1062 }
1063 }
1064 Ok(out)
1065 }
1066
1067 /// `create` — create a record (server-assigned rkey). Returns its strong ref.
1068 pub async fn create_record<T: Serialize>(
1069 &self,
1070 did: &str,
1071 collection: &str,
1072 record: &T,
1073 ) -> Result<WriteResult> {
1074 let body = json!({
1075 "did": did,
1076 "action": RepoAction::Create.as_str(),
1077 "collection": collection,
1078 "record": record,
1079 });
1080 let data = self.repo(body).await?;
1081 serde_json::from_value(data).context("parsing sidecar createRecord data")
1082 }
1083
1084 /// `put` — upsert a record at a known rkey. Returns its strong ref.
1085 pub async fn put_record<T: Serialize>(
1086 &self,
1087 did: &str,
1088 collection: &str,
1089 rkey: &str,
1090 record: &T,
1091 ) -> Result<WriteResult> {
1092 let body = json!({
1093 "did": did,
1094 "action": RepoAction::Put.as_str(),
1095 "collection": collection,
1096 "rkey": rkey,
1097 "record": record,
1098 });
1099 let data = self.repo(body).await?;
1100 serde_json::from_value(data).context("parsing sidecar putRecord data")
1101 }
1102
1103 /// `delete` — delete a record by collection + rkey.
1104 pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<()> {
1105 let body = json!({
1106 "did": did,
1107 "action": RepoAction::Delete.as_str(),
1108 "collection": collection,
1109 "rkey": rkey,
1110 });
1111 self.repo(body).await?;
1112 Ok(())
1113 }
1114
1115 /// `applyWrites` — a batch of create/update/delete ops in one round-trip.
1116 pub async fn apply_writes(&self, did: &str, writes: &[WriteOp]) -> Result<()> {
1117 if writes.is_empty() {
1118 return Ok(());
1119 }
1120 let ops: Vec<Value> = writes.iter().map(WriteOp::to_sidecar_json).collect();
1121 let body = json!({
1122 "did": did,
1123 "action": RepoAction::ApplyWrites.as_str(),
1124 "writes": ops,
1125 });
1126 self.repo(body).await?;
1127 Ok(())
1128 }
1129
1130 // -- typed lexicon wrappers (mirror the old PdsClient surface) ------------
1131
1132 /// List every [`Subscription`] record in `did`'s repo (paged fully).
1133 pub async fn list_subscriptions(&self, did: &str) -> Result<Vec<(String, Subscription)>> {
1134 self.list_typed(did, lexicon::nsid::SUBSCRIPTION).await
1135 }
1136
1137 /// Create a [`Subscription`] record (subscribe to a feed).
1138 pub async fn create_subscription(&self, did: &str, sub: &Subscription) -> Result<WriteResult> {
1139 self.create_record(did, lexicon::nsid::SUBSCRIPTION, sub)
1140 .await
1141 }
1142
1143 /// Delete a [`Subscription`] record by rkey (unsubscribe).
1144 pub async fn delete_subscription(&self, did: &str, rkey: &str) -> Result<()> {
1145 self.delete_record(did, lexicon::nsid::SUBSCRIPTION, rkey)
1146 .await
1147 }
1148
1149 /// Batch-create many [`Subscription`] records in one `applyWrites` — the OPML
1150 /// import path (one create op per feed, server-assigned rkeys).
1151 pub async fn create_subscriptions_batch(&self, did: &str, subs: &[Subscription]) -> Result<()> {
1152 let writes: Vec<WriteOp> = subs
1153 .iter()
1154 .map(|sub| {
1155 Ok(WriteOp::Create {
1156 collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1157 rkey: None,
1158 value: serde_json::to_value(sub)?,
1159 })
1160 })
1161 .collect::<Result<_>>()?;
1162 self.apply_writes(did, &writes).await
1163 }
1164
1165 /// List every [`Folder`] record in `did`'s repo.
1166 pub async fn list_folders(&self, did: &str) -> Result<Vec<(String, Folder)>> {
1167 self.list_typed(did, lexicon::nsid::FOLDER).await
1168 }
1169
1170 /// List every [`Saved`] record in `did`'s repo.
1171 pub async fn list_saved(&self, did: &str) -> Result<Vec<(String, Saved)>> {
1172 self.list_typed(did, lexicon::nsid::SAVED).await
1173 }
1174
1175 /// List every [`ReadState`] cursor in `did`'s repo (the read side a
1176 /// login-time read-state merge would consume).
1177 pub async fn list_read_states(&self, did: &str) -> Result<Vec<(String, ReadState)>> {
1178 self.list_typed(did, lexicon::nsid::READ_STATE).await
1179 }
1180
1181 /// Upsert a single [`ReadState`] cursor at its feed-derived rkey.
1182 pub async fn put_read_state(
1183 &self,
1184 did: &str,
1185 rkey: &str,
1186 state: &ReadState,
1187 ) -> Result<WriteResult> {
1188 self.put_record(did, lexicon::nsid::READ_STATE, rkey, state)
1189 .await
1190 }
1191
1192 /// Batch-flush many dirty [`ReadState`] cursors in one `applyWrites` call.
1193 ///
1194 /// Each `(rkey, state, pds_created)` becomes a `create` op at the feed-derived
1195 /// rkey when the record does NOT yet exist (`pds_created == false`), and an
1196 /// `update` op when it does. This is what makes the FIRST flush of a feed
1197 /// succeed: `applyWrites#update` errors on a record that does not pre-exist,
1198 /// and `applyWrites` is atomic per-repo, so a single not-yet-created cursor
1199 /// would otherwise drop the whole DID batch. Both kinds ride the SAME
1200 /// `applyWrites` batch so batching is preserved.
1201 pub async fn flush_read_states(
1202 &self,
1203 did: &str,
1204 cursors: &[(String, ReadState, bool)],
1205 ) -> Result<()> {
1206 if cursors.is_empty() {
1207 return Ok(());
1208 }
1209 let writes = read_state_write_ops(cursors)?;
1210 self.apply_writes(did, &writes).await
1211 }
1212
1213 // -- reader-facing record CRUD (the surface the web layer calls) ----------
1214 //
1215 // These are the typed convenience methods `web.rs` uses to manage a user's
1216 // feeds/folders/saved items *as records in their PDS*. They mirror the
1217 // create/list surface above but use the reader vocabulary
1218 // (add/remove/rename) and, for the `add_*` verbs, return the server-assigned
1219 // rkey so the caller can address the new record without a re-list. Ordering
1220 // is made deterministic where it matters (see [`list_subscriptions_sorted`]
1221 // etc.) so the server-rendered HTML is stable between reads.
1222
1223 // -- subscriptions -------------------------------------------------------
1224
1225 /// Add a subscription (subscribe to a feed) — `createRecord`, server-assigned
1226 /// `tid` rkey. Returns the new record's **rkey** so the web layer can offer
1227 /// unsubscribe/rename immediately.
1228 pub async fn add_subscription(&self, did: &str, sub: &Subscription) -> Result<String> {
1229 Ok(self.create_subscription(did, sub).await?.into_rkey())
1230 }
1231
1232 /// Remove a subscription (unsubscribe) by rkey — `deleteRecord`. Alias of
1233 /// [`delete_subscription`](Self::delete_subscription) in the reader vocabulary.
1234 pub async fn remove_subscription(&self, did: &str, rkey: &str) -> Result<()> {
1235 self.delete_subscription(did, rkey).await
1236 }
1237
1238 /// Update / rename a subscription in place at a known rkey — `putRecord`.
1239 ///
1240 /// The whole record is replaced (retitle, move to a folder, change the
1241 /// fetch hint …). Upsert semantics: it also creates the record if the rkey
1242 /// is somehow absent, so it is safe as a general "write this exact record".
1243 pub async fn update_subscription(
1244 &self,
1245 did: &str,
1246 rkey: &str,
1247 sub: &Subscription,
1248 ) -> Result<WriteResult> {
1249 self.put_record(did, lexicon::nsid::SUBSCRIPTION, rkey, sub)
1250 .await
1251 }
1252
1253 /// List every subscription, **sorted deterministically** — by display title
1254 /// (case-insensitive), then feed URL, then rkey as the final tiebreaker — so
1255 /// the rendered feed list is stable across reads regardless of PDS return
1256 /// order. Untitled feeds sort by their URL.
1257 pub async fn list_subscriptions_sorted(
1258 &self,
1259 did: &str,
1260 ) -> Result<Vec<(String, Subscription)>> {
1261 let mut subs = self.list_subscriptions(did).await?;
1262 subs.sort_by(|(a_key, a), (b_key, b)| {
1263 let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
1264 let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
1265 a_title
1266 .cmp(&b_title)
1267 .then_with(|| a.url.cmp(&b.url))
1268 .then_with(|| a_key.cmp(b_key))
1269 });
1270 Ok(subs)
1271 }
1272
1273 /// Batch-add many subscriptions in one `applyWrites` — the OPML-import path.
1274 ///
1275 /// Each feed becomes one `create` op. Client-side monotonic [`tid`](tid)
1276 /// rkeys are assigned so the batch is deterministic and the imported feeds
1277 /// keep OPML order (server-assigned tids would also be monotonic, but pinning
1278 /// them here makes the whole import reproducible and testable offline).
1279 /// Returns the assigned rkeys in input order.
1280 pub async fn add_subscriptions_bulk(
1281 &self,
1282 did: &str,
1283 subs: &[Subscription],
1284 ) -> Result<Vec<String>> {
1285 let mut gen = TidGenerator::new();
1286 let mut rkeys = Vec::with_capacity(subs.len());
1287 let mut writes = Vec::with_capacity(subs.len());
1288 for sub in subs {
1289 let rkey = gen.next();
1290 writes.push(WriteOp::Create {
1291 collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1292 rkey: Some(rkey.clone()),
1293 value: serde_json::to_value(sub)?,
1294 });
1295 rkeys.push(rkey);
1296 }
1297 self.apply_writes(did, &writes).await?;
1298 Ok(rkeys)
1299 }
1300
1301 // -- folders -------------------------------------------------------------
1302
1303 /// Add a folder — `createRecord`, server-assigned `tid` rkey. Returns the
1304 /// new folder's rkey (subscriptions reference it by its `at://` URI).
1305 pub async fn add_folder(&self, did: &str, folder: &Folder) -> Result<String> {
1306 Ok(self
1307 .create_record(did, lexicon::nsid::FOLDER, folder)
1308 .await?
1309 .into_rkey())
1310 }
1311
1312 /// Remove a folder by rkey — `deleteRecord`. (Subscriptions referencing it
1313 /// are left untouched; a dangling `folder` ref reads as "unfiled".)
1314 pub async fn remove_folder(&self, did: &str, rkey: &str) -> Result<()> {
1315 self.delete_record(did, lexicon::nsid::FOLDER, rkey).await
1316 }
1317
1318 /// Rename / update a folder in place at a known rkey — `putRecord`
1319 /// (rename, or change its `position` sort hint).
1320 pub async fn rename_folder(
1321 &self,
1322 did: &str,
1323 rkey: &str,
1324 folder: &Folder,
1325 ) -> Result<WriteResult> {
1326 self.put_record(did, lexicon::nsid::FOLDER, rkey, folder)
1327 .await
1328 }
1329
1330 /// List every folder, **sorted deterministically** — by `position` (the
1331 /// lexicon's sort hint; unset sorts last), then name (case-insensitive),
1332 /// then rkey — so the sidebar order is stable.
1333 pub async fn list_folders_sorted(&self, did: &str) -> Result<Vec<(String, Folder)>> {
1334 let mut folders = self.list_folders(did).await?;
1335 folders.sort_by(|(a_key, a), (b_key, b)| {
1336 a.position
1337 .unwrap_or(u64::MAX)
1338 .cmp(&b.position.unwrap_or(u64::MAX))
1339 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1340 .then_with(|| a_key.cmp(b_key))
1341 });
1342 Ok(folders)
1343 }
1344
1345 // -- saved / starred -----------------------------------------------------
1346
1347 /// Add a saved (starred / save-for-later) entry — `createRecord`,
1348 /// server-assigned `tid` rkey. Returns the new record's rkey.
1349 pub async fn add_saved(&self, did: &str, saved: &Saved) -> Result<String> {
1350 Ok(self
1351 .create_record(did, lexicon::nsid::SAVED, saved)
1352 .await?
1353 .into_rkey())
1354 }
1355
1356 /// Remove a saved entry by rkey — `deleteRecord` (un-star).
1357 pub async fn remove_saved(&self, did: &str, rkey: &str) -> Result<()> {
1358 self.delete_record(did, lexicon::nsid::SAVED, rkey).await
1359 }
1360
1361 /// List every saved entry, **sorted deterministically** — newest first by
1362 /// `createdAt` (RFC-3339 sorts lexicographically), then rkey — so the
1363 /// "saved for later" list reads most-recent-first and is stable.
1364 pub async fn list_saved_sorted(&self, did: &str) -> Result<Vec<(String, Saved)>> {
1365 let mut saved = self.list_saved(did).await?;
1366 saved.sort_by(|(a_key, a), (b_key, b)| {
1367 b.created_at
1368 .cmp(&a.created_at)
1369 .then_with(|| a_key.cmp(b_key))
1370 });
1371 Ok(saved)
1372 }
1373
1374 /// List a collection for `did` and parse each record's value into `T`,
1375 /// pairing it with its rkey. Unparseable records are skipped with a warning
1376 /// (forward-compat).
1377 async fn list_typed<T: DeserializeOwned>(
1378 &self,
1379 did: &str,
1380 collection: &str,
1381 ) -> Result<Vec<(String, T)>> {
1382 let records = self.list_all_records(did, collection).await?;
1383 let mut out = Vec::with_capacity(records.len());
1384 for rec in records {
1385 let rkey = rec.rkey().unwrap_or_default().to_string();
1386 match rec.parse::<T>() {
1387 Ok(value) => out.push((rkey, value)),
1388 Err(e) => tracing::warn!(
1389 collection,
1390 uri = %rec.uri,
1391 error = %e,
1392 "skipping unparseable record in collection"
1393 ),
1394 }
1395 }
1396 Ok(out)
1397 }
1398}
1399
1400// ---------------------------------------------------------------------------
1401// applyWrites operations
1402// ---------------------------------------------------------------------------
1403
1404/// Build the `applyWrites` ops for a batch of dirty read-state cursors.
1405///
1406/// Each `(rkey, state, pds_created)` becomes a `#create` op (at the stable
1407/// feed-derived rkey) when the PDS record does NOT yet exist, and a `#update`
1408/// when it does. This is the crux of the first-flush fix: an `#update` on a
1409/// missing record errors, and `applyWrites` is atomic per-repo, so a single
1410/// not-yet-created cursor in the batch would drop the whole DID's flush. Emitting
1411/// a `create` for those makes a feed's first flush succeed while keeping every
1412/// op in ONE batch. Shared by both the sidecar and direct-PDS flush paths.
1413fn read_state_write_ops(cursors: &[(String, ReadState, bool)]) -> Result<Vec<WriteOp>> {
1414 cursors
1415 .iter()
1416 .map(|(rkey, state, pds_created)| {
1417 let value = serde_json::to_value(state)?;
1418 Ok(if *pds_created {
1419 WriteOp::Update {
1420 collection: lexicon::nsid::READ_STATE.to_string(),
1421 rkey: rkey.clone(),
1422 value,
1423 }
1424 } else {
1425 WriteOp::Create {
1426 collection: lexicon::nsid::READ_STATE.to_string(),
1427 rkey: Some(rkey.clone()),
1428 value,
1429 }
1430 })
1431 })
1432 .collect()
1433}
1434
1435/// One operation in a [`PdsClient::apply_writes`] batch.
1436///
1437/// Maps to the `com.atproto.repo.applyWrites` union of
1438/// `#create` / `#update` / `#delete`.
1439#[derive(Debug, Clone)]
1440pub enum WriteOp {
1441 /// Create a record (server-assigned rkey unless `rkey` is given).
1442 Create {
1443 /// The collection NSID.
1444 collection: String,
1445 /// Optional explicit rkey (`None` → server assigns a tid).
1446 rkey: Option<String>,
1447 /// The record body.
1448 value: Value,
1449 },
1450 /// Upsert a record at a known rkey (the read-state cursor case).
1451 Update {
1452 /// The collection NSID.
1453 collection: String,
1454 /// The rkey to write at.
1455 rkey: String,
1456 /// The record body.
1457 value: Value,
1458 },
1459 /// Delete a record by collection + rkey.
1460 Delete {
1461 /// The collection NSID.
1462 collection: String,
1463 /// The rkey to delete.
1464 rkey: String,
1465 },
1466}
1467
1468impl WriteOp {
1469 /// Render this op as the tagged JSON `com.atproto.repo.applyWrites` expects.
1470 fn to_json(&self) -> Value {
1471 match self {
1472 WriteOp::Create {
1473 collection,
1474 rkey,
1475 value,
1476 } => {
1477 let mut op = json!({
1478 "$type": "com.atproto.repo.applyWrites#create",
1479 "collection": collection,
1480 "value": value,
1481 });
1482 if let Some(rkey) = rkey {
1483 op["rkey"] = json!(rkey);
1484 }
1485 op
1486 }
1487 WriteOp::Update {
1488 collection,
1489 rkey,
1490 value,
1491 } => json!({
1492 "$type": "com.atproto.repo.applyWrites#update",
1493 "collection": collection,
1494 "rkey": rkey,
1495 "value": value,
1496 }),
1497 WriteOp::Delete { collection, rkey } => json!({
1498 "$type": "com.atproto.repo.applyWrites#delete",
1499 "collection": collection,
1500 "rkey": rkey,
1501 }),
1502 }
1503 }
1504
1505 /// Render this op in the shape the OAuth sidecar's `/internal/repo`
1506 /// `applyWrites` expects: `{action, collection, rkey?, value?}` (the sidecar
1507 /// maps `action` → the `com.atproto.repo.applyWrites#<kind>` union member).
1508 fn to_sidecar_json(&self) -> Value {
1509 match self {
1510 WriteOp::Create {
1511 collection,
1512 rkey,
1513 value,
1514 } => {
1515 let mut op = json!({
1516 "action": "create",
1517 "collection": collection,
1518 "value": value,
1519 });
1520 if let Some(rkey) = rkey {
1521 op["rkey"] = json!(rkey);
1522 }
1523 op
1524 }
1525 WriteOp::Update {
1526 collection,
1527 rkey,
1528 value,
1529 } => json!({
1530 "action": "update",
1531 "collection": collection,
1532 "rkey": rkey,
1533 "value": value,
1534 }),
1535 WriteOp::Delete { collection, rkey } => json!({
1536 "action": "delete",
1537 "collection": collection,
1538 "rkey": rkey,
1539 }),
1540 }
1541 }
1542}
1543
1544// ---------------------------------------------------------------------------
1545// TID rkeys (client-assigned, sortable, deterministic within a batch)
1546// ---------------------------------------------------------------------------
1547
1548/// The atproto base32-sortable alphabet (`s32`) — the digits/letters, minus the
1549/// ambiguous set, in **ascending** order so a bytewise string compare of two
1550/// TIDs matches their timestamp order.
1551const S32_ALPHABET: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
1552
1553/// A monotonic generator of atproto **TID** record keys.
1554///
1555/// A TID is a 13-char `s32`-encoded 64-bit integer: a 53-bit microsecond
1556/// timestamp in the high bits and a 10-bit "clock id" in the low bits (the top
1557/// bit is always 0). Encoded in the ascending `s32` alphabet, TIDs sort
1558/// lexicographically in creation order — which is exactly what we want for a
1559/// batched OPML import: assigning the rkeys ourselves keeps the imported feeds
1560/// in input order and makes [`add_subscriptions_bulk`](SidecarClient::add_subscriptions_bulk)
1561/// fully reproducible/testable without a live PDS.
1562///
1563/// Monotonicity within one generator is guaranteed by tracking the last value
1564/// and bumping to `last + 1` if the clock hasn't advanced — so a burst of
1565/// same-microsecond calls still yields strictly increasing, ordered rkeys.
1566struct TidGenerator {
1567 /// The last raw 64-bit TID value emitted (0 = none yet).
1568 last: u64,
1569 /// The low-10-bit clock id, randomized once per generator to avoid
1570 /// cross-instance collisions on the same microsecond.
1571 clock_id: u64,
1572}
1573
1574impl TidGenerator {
1575 /// A fresh generator with a per-instance clock id derived from the current
1576 /// nanosecond clock (no extra deps; uniqueness only needs to hold within a
1577 /// single import batch, and the timestamp bits carry the ordering).
1578 fn new() -> Self {
1579 let nanos = std::time::SystemTime::now()
1580 .duration_since(std::time::UNIX_EPOCH)
1581 .map(|d| d.subsec_nanos() as u64)
1582 .unwrap_or(0);
1583 Self {
1584 last: 0,
1585 clock_id: nanos & 0x3ff,
1586 }
1587 }
1588
1589 /// The next monotonic TID rkey (13 `s32` chars).
1590 fn next(&mut self) -> String {
1591 let micros = std::time::SystemTime::now()
1592 .duration_since(std::time::UNIX_EPOCH)
1593 .map(|d| d.as_micros() as u64)
1594 .unwrap_or(0);
1595 // Timestamp in bits 63..10 (top bit stays 0), clock id in bits 9..0.
1596 let mut raw = ((micros & 0x001f_ffff_ffff_ffff) << 10) | self.clock_id;
1597 if raw <= self.last {
1598 raw = self.last + 1;
1599 }
1600 self.last = raw;
1601 encode_s32_tid(raw)
1602 }
1603}
1604
1605/// Encode a 64-bit TID value as a 13-char big-endian `s32` string.
1606fn encode_s32_tid(mut v: u64) -> String {
1607 let mut buf = [0u8; 13];
1608 for slot in buf.iter_mut().rev() {
1609 *slot = S32_ALPHABET[(v & 0x1f) as usize];
1610 v >>= 5;
1611 }
1612 // 13 * 5 = 65 bits cover the 64-bit value; the leading char holds the top
1613 // (always-0) bit, so it is always the alphabet's first symbol.
1614 String::from_utf8(buf.to_vec()).unwrap_or_default()
1615}
1616
1617// ---------------------------------------------------------------------------
1618// XRPC error helper
1619// ---------------------------------------------------------------------------
1620
1621/// Minimal percent-encoding for a query-string component.
1622///
1623/// Encodes everything outside the RFC 3986 unreserved set, which covers the
1624/// values FeatherReader passes (DIDs like `did:plc:…`, NSIDs, opaque cursors,
1625/// handles) without pulling in the optional reqwest `url`/`query` feature.
1626fn urlencode(s: &str) -> String {
1627 let mut out = String::with_capacity(s.len());
1628 for b in s.bytes() {
1629 match b {
1630 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1631 out.push(b as char)
1632 }
1633 _ => out.push_str(&format!("%{b:02X}")),
1634 }
1635 }
1636 out
1637}
1638
1639/// The atproto XRPC error envelope body: `{"error": "...", "message": "..."}`.
1640#[derive(Debug, Deserialize)]
1641struct XrpcErrorBody {
1642 #[serde(default)]
1643 error: Option<String>,
1644 #[serde(default)]
1645 message: Option<String>,
1646}
1647
1648/// Consume a non-2xx response into a typed [`AtProtoError::Xrpc`], parsing the
1649/// atproto error envelope when present (falling back to `"Unknown"`).
1650async fn xrpc_error_from(resp: reqwest::Response) -> AtProtoError {
1651 let status = resp.status();
1652 let (error, message) = match resp.json::<XrpcErrorBody>().await {
1653 Ok(body) => (
1654 body.error.unwrap_or_else(|| "Unknown".to_string()),
1655 body.message,
1656 ),
1657 Err(_) => ("Unknown".to_string(), None),
1658 };
1659 AtProtoError::Xrpc {
1660 status,
1661 error,
1662 message,
1663 }
1664}
1665
1666// ---------------------------------------------------------------------------
1667// Tests — record (de)serialization against a repo listRecords response shape.
1668// No network.
1669// ---------------------------------------------------------------------------
1670
1671#[cfg(test)]
1672mod tests {
1673 use super::*;
1674
1675 /// A realistic `com.atproto.repo.listRecords` response for the subscription
1676 /// collection, as a PDS returns it — the envelope wraps each record in
1677 /// `{uri, cid, value}` and the record `value` carries its `$type`.
1678 fn subscription_list_json() -> Value {
1679 json!({
1680 "records": [
1681 {
1682 "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0001",
1683 "cid": "bafyreisubone",
1684 "value": {
1685 "$type": "community.lexicon.rss.subscription",
1686 "url": "https://example.com/feed.xml",
1687 "title": "Example Blog",
1688 "siteUrl": "https://example.com/",
1689 "fetchHint": "hourly",
1690 "createdAt": "2026-07-12T00:00:00.000Z"
1691 }
1692 },
1693 {
1694 "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0002",
1695 "cid": "bafyreisubtwo",
1696 "value": {
1697 "$type": "community.lexicon.rss.subscription",
1698 "url": "https://blog.example.org/atom.xml",
1699 "createdAt": "2026-07-11T12:00:00.000Z"
1700 }
1701 }
1702 ],
1703 "cursor": "3ksub0002"
1704 })
1705 }
1706
1707 #[test]
1708 fn list_records_envelope_deserializes() {
1709 let resp: ListRecordsResponse =
1710 serde_json::from_value(subscription_list_json()).expect("envelope");
1711 assert_eq!(resp.records.len(), 2);
1712 assert_eq!(resp.cursor.as_deref(), Some("3ksub0002"));
1713 assert_eq!(resp.records[0].cid.as_deref(), Some("bafyreisubone"));
1714 }
1715
1716 #[test]
1717 fn record_entry_rkey_is_last_uri_segment() {
1718 let resp: ListRecordsResponse =
1719 serde_json::from_value(subscription_list_json()).expect("envelope");
1720 assert_eq!(resp.records[0].rkey(), Some("3ksub0001"));
1721 assert_eq!(resp.records[1].rkey(), Some("3ksub0002"));
1722 }
1723
1724 #[test]
1725 fn record_value_parses_into_lexicon_subscription() {
1726 let resp: ListRecordsResponse =
1727 serde_json::from_value(subscription_list_json()).expect("envelope");
1728
1729 let full: Subscription = resp.records[0].parse().expect("parse full sub");
1730 assert_eq!(full.r#type, lexicon::nsid::SUBSCRIPTION);
1731 assert_eq!(full.url, "https://example.com/feed.xml");
1732 assert_eq!(full.title.as_deref(), Some("Example Blog"));
1733 assert_eq!(full.site_url.as_deref(), Some("https://example.com/"));
1734 assert_eq!(full.fetch_hint, Some(lexicon::FetchHint::Hourly));
1735
1736 let minimal: Subscription = resp.records[1].parse().expect("parse minimal sub");
1737 assert_eq!(minimal.url, "https://blog.example.org/atom.xml");
1738 assert!(minimal.title.is_none());
1739 }
1740
1741 fn ssrf_test_client() -> Client {
1742 Client::builder()
1743 .user_agent(crate::USER_AGENT)
1744 .build()
1745 .unwrap()
1746 }
1747
1748 /// A hostile `did:web` whose host is the cloud-metadata address must be
1749 /// REFUSED before any request leaves the box — the DID-document fetch now
1750 /// routes through the SSRF guard (`guarded_get_no_privacy`), which rejects
1751 /// link-local / metadata targets.
1752 #[tokio::test]
1753 async fn resolve_did_web_blocks_metadata_host() {
1754 let client = ssrf_test_client();
1755 let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:169.254.169.254")
1756 .await
1757 .unwrap_err()
1758 .to_string();
1759 assert!(
1760 err.contains("forbidden") || err.contains("internal"),
1761 "expected an SSRF refusal, got: {err}"
1762 );
1763 }
1764
1765 /// A `did:web` pointing at loopback is likewise blocked (internal service
1766 /// reflection).
1767 #[tokio::test]
1768 async fn resolve_did_web_blocks_loopback_host() {
1769 let client = ssrf_test_client();
1770 let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:127.0.0.1")
1771 .await
1772 .unwrap_err()
1773 .to_string();
1774 assert!(
1775 err.contains("forbidden") || err.contains("internal"),
1776 "expected an SSRF refusal, got: {err}"
1777 );
1778 }
1779
1780 /// `resolve_handle` against a metadata/loopback resolver base is also guarded
1781 /// (the base can come from a prior hostile DID-doc resolution).
1782 #[tokio::test]
1783 async fn resolve_handle_blocks_metadata_resolver_base() {
1784 let client = ssrf_test_client();
1785 let err = resolve_handle(&client, "http://169.254.169.254", "alice.example.com")
1786 .await
1787 .unwrap_err()
1788 .to_string();
1789 assert!(
1790 err.contains("forbidden") || err.contains("internal"),
1791 "expected an SSRF refusal, got: {err}"
1792 );
1793 }
1794
1795 /// A resolved `serviceEndpoint` that targets an internal host is rejected at
1796 /// resolve time via [`crate::net::assert_public_target`], so it can never be
1797 /// handed to a raw XRPC client.
1798 #[tokio::test]
1799 async fn service_endpoint_internal_target_rejected() {
1800 assert!(crate::net::assert_public_target("http://169.254.169.254/")
1801 .await
1802 .is_err());
1803 assert!(crate::net::assert_public_target("http://127.0.0.1:3000/")
1804 .await
1805 .is_err());
1806 // A public endpoint literal passes.
1807 assert!(crate::net::assert_public_target("https://1.1.1.1/")
1808 .await
1809 .is_ok());
1810 }
1811
1812 #[test]
1813 fn write_result_deserializes() {
1814 let wr: WriteResult = serde_json::from_value(json!({
1815 "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
1816 "cid": "bafyreinew"
1817 }))
1818 .expect("write result");
1819 assert!(wr.uri.ends_with("3ksubnew"));
1820 assert_eq!(wr.cid.as_deref(), Some("bafyreinew"));
1821 }
1822
1823 #[test]
1824 fn did_document_finds_pds_endpoint() {
1825 let doc: DidDocument = serde_json::from_value(json!({
1826 "id": "did:plc:abc123",
1827 "service": [
1828 {
1829 "id": "#atproto_pds",
1830 "type": "AtprotoPersonalDataServer",
1831 "serviceEndpoint": "https://pds.example.com/"
1832 }
1833 ]
1834 }))
1835 .expect("did doc");
1836 assert_eq!(
1837 doc.pds_endpoint().as_deref(),
1838 Some("https://pds.example.com")
1839 );
1840 }
1841
1842 #[test]
1843 fn did_document_without_pds_yields_none() {
1844 let doc: DidDocument = serde_json::from_value(json!({
1845 "id": "did:plc:abc123",
1846 "service": []
1847 }))
1848 .expect("did doc");
1849 assert!(doc.pds_endpoint().is_none());
1850 }
1851
1852 #[test]
1853 fn session_auth_deserializes_create_session_shape() {
1854 let session: SessionAuth = serde_json::from_value(json!({
1855 "did": "did:plc:abc123",
1856 "handle": "alice.example.com",
1857 "accessJwt": "eyJh...access",
1858 "refreshJwt": "eyJh...refresh"
1859 }))
1860 .expect("session");
1861 assert_eq!(session.did, "did:plc:abc123");
1862 assert_eq!(session.handle.as_deref(), Some("alice.example.com"));
1863 let auth = Auth::Session(session);
1864 assert_eq!(auth.bearer().expect("bearer"), "eyJh...access");
1865 }
1866
1867 #[test]
1868 fn oauth_variant_carries_no_direct_bearer() {
1869 let auth = Auth::Oauth(OauthPlaceholder::default());
1870 assert!(
1871 auth.bearer().is_err(),
1872 "Auth::Oauth carries no direct bearer — the sidecar owns the OAuth path"
1873 );
1874 }
1875
1876 #[test]
1877 fn apply_writes_ops_render_tagged_union() {
1878 let create = WriteOp::Create {
1879 collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1880 rkey: None,
1881 value: json!({"url": "https://example.com/feed.xml"}),
1882 };
1883 let update = WriteOp::Update {
1884 collection: lexicon::nsid::READ_STATE.to_string(),
1885 rkey: "feedhash01".to_string(),
1886 value: json!({"feedUrl": "https://example.com/feed.xml"}),
1887 };
1888 let delete = WriteOp::Delete {
1889 collection: lexicon::nsid::SAVED.to_string(),
1890 rkey: "3ksaved01".to_string(),
1891 };
1892
1893 assert_eq!(
1894 create.to_json()["$type"],
1895 json!("com.atproto.repo.applyWrites#create")
1896 );
1897 // A create with no explicit rkey omits the field (server assigns a tid).
1898 assert!(create.to_json().get("rkey").is_none());
1899
1900 assert_eq!(
1901 update.to_json()["$type"],
1902 json!("com.atproto.repo.applyWrites#update")
1903 );
1904 assert_eq!(update.to_json()["rkey"], json!("feedhash01"));
1905
1906 assert_eq!(
1907 delete.to_json()["$type"],
1908 json!("com.atproto.repo.applyWrites#delete")
1909 );
1910 assert_eq!(delete.to_json()["rkey"], json!("3ksaved01"));
1911 }
1912
1913 #[test]
1914 fn read_state_flush_creates_first_then_updates() {
1915 // A cursor whose PDS record does NOT yet exist (pds_created = false) must
1916 // become a CREATE op at its stable rkey — NOT a bare update, which would
1917 // error on the missing record and (applyWrites being atomic per-repo) drop
1918 // the whole batch on a feed's first flush.
1919 let fresh = (
1920 "rs-fresh".to_string(),
1921 ReadState::new("https://a.example/feed.xml", None, "2026-07-12T00:00:00Z"),
1922 false,
1923 );
1924 // An already-created cursor updates in place.
1925 let existing = (
1926 "rs-existing".to_string(),
1927 ReadState::new(
1928 "https://b.example/feed.xml",
1929 Some("2026-07-11T00:00:00Z".to_string()),
1930 "2026-07-12T00:00:00Z",
1931 ),
1932 true,
1933 );
1934
1935 let ops = read_state_write_ops(&[fresh, existing]).expect("build ops");
1936 assert_eq!(ops.len(), 2);
1937
1938 // First op: a create carrying the stable rkey (put/create, not update).
1939 let create = ops[0].to_json();
1940 assert_eq!(
1941 create["$type"],
1942 json!("com.atproto.repo.applyWrites#create"),
1943 "first flush of a new feed must CREATE its readState record"
1944 );
1945 assert_eq!(create["rkey"], json!("rs-fresh"));
1946 // The created record omits readThrough (F1): backlog not implicitly read.
1947 assert!(create["value"].get("readThrough").is_none());
1948
1949 // Second op: an update for the already-created record.
1950 let update = ops[1].to_json();
1951 assert_eq!(
1952 update["$type"],
1953 json!("com.atproto.repo.applyWrites#update")
1954 );
1955 assert_eq!(update["rkey"], json!("rs-existing"));
1956
1957 // Both ride the SAME batch — batching is preserved.
1958 assert_eq!(ops.len(), 2);
1959 }
1960
1961 #[test]
1962 fn urlencode_escapes_did_colons_and_keeps_unreserved() {
1963 assert_eq!(urlencode("did:plc:abc123"), "did%3Aplc%3Aabc123");
1964 assert_eq!(
1965 urlencode("community.lexicon.rss.subscription"),
1966 "community.lexicon.rss.subscription"
1967 );
1968 assert_eq!(urlencode("a b&c"), "a%20b%26c");
1969 }
1970
1971 #[test]
1972 fn xrpc_record_not_found_is_detected() {
1973 let err = AtProtoError::Xrpc {
1974 status: StatusCode::BAD_REQUEST,
1975 error: "RecordNotFound".to_string(),
1976 message: Some("Could not locate record".to_string()),
1977 };
1978 assert!(err.is_record_not_found());
1979 }
1980
1981 // -- reader-facing CRUD: rkey extraction --------------------------------
1982
1983 #[test]
1984 fn write_result_extracts_rkey_from_uri() {
1985 let wr: WriteResult = serde_json::from_value(json!({
1986 "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
1987 "cid": "bafyreinew"
1988 }))
1989 .expect("write result");
1990 assert_eq!(wr.rkey(), Some("3ksubnew"));
1991 assert_eq!(wr.into_rkey(), "3ksubnew");
1992 }
1993
1994 // -- reader-facing CRUD: deterministic sort orders ----------------------
1995 //
1996 // The `list_*_sorted` wrappers only add an ordering on top of the network
1997 // `list_*` read, so we exercise the *comparator* here on representative
1998 // data (parsed from a listRecords-shaped envelope) with no network.
1999
2000 fn parse_all<T: DeserializeOwned>(v: Value) -> Vec<(String, T)> {
2001 let resp: ListRecordsResponse = serde_json::from_value(v).expect("envelope");
2002 resp.records
2003 .into_iter()
2004 .map(|r| {
2005 let rkey = r.rkey().unwrap_or_default().to_string();
2006 (rkey, r.parse::<T>().expect("parse"))
2007 })
2008 .collect()
2009 }
2010
2011 fn sort_subscriptions(subs: &mut [(String, Subscription)]) {
2012 subs.sort_by(|(a_key, a), (b_key, b)| {
2013 let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
2014 let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
2015 a_title
2016 .cmp(&b_title)
2017 .then_with(|| a.url.cmp(&b.url))
2018 .then_with(|| a_key.cmp(b_key))
2019 });
2020 }
2021
2022 #[test]
2023 fn subscriptions_sort_by_title_then_url_then_rkey() {
2024 let mut subs: Vec<(String, Subscription)> = parse_all(json!({
2025 "records": [
2026 {
2027 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-zebra",
2028 "value": { "url": "https://z.example/feed", "title": "Zebra News",
2029 "createdAt": "2026-07-12T00:00:00.000Z" }
2030 },
2031 {
2032 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-untitled",
2033 "value": { "url": "https://aaa.example/feed",
2034 "createdAt": "2026-07-12T00:00:00.000Z" }
2035 },
2036 {
2037 "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-apple",
2038 "value": { "url": "https://apple.example/feed", "title": "apple blog",
2039 "createdAt": "2026-07-12T00:00:00.000Z" }
2040 }
2041 ]
2042 }));
2043 sort_subscriptions(&mut subs);
2044 // Case-insensitive by the display key (title, or URL when untitled):
2045 // "apple blog" < "https://aaa.example/feed" < "zebra news".
2046 let order: Vec<&str> = subs.iter().map(|(k, _)| k.as_str()).collect();
2047 assert_eq!(order, vec!["rk-apple", "rk-untitled", "rk-zebra"]);
2048 }
2049
2050 #[test]
2051 fn folders_sort_by_position_then_name_then_rkey() {
2052 let mut folders: Vec<(String, Folder)> = parse_all(json!({
2053 "records": [
2054 {
2055 "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-nopos",
2056 "value": { "name": "Aardvark", "createdAt": "2026-07-12T00:00:00.000Z" }
2057 },
2058 {
2059 "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos2",
2060 "value": { "name": "Tech", "position": 2, "createdAt": "2026-07-12T00:00:00.000Z" }
2061 },
2062 {
2063 "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos0",
2064 "value": { "name": "News", "position": 0, "createdAt": "2026-07-12T00:00:00.000Z" }
2065 }
2066 ]
2067 }));
2068 folders.sort_by(|(a_key, a), (b_key, b)| {
2069 a.position
2070 .unwrap_or(u64::MAX)
2071 .cmp(&b.position.unwrap_or(u64::MAX))
2072 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
2073 .then_with(|| a_key.cmp(b_key))
2074 });
2075 let order: Vec<&str> = folders.iter().map(|(k, _)| k.as_str()).collect();
2076 // position 0, then 2, then the unset (sorts last despite name "Aardvark").
2077 assert_eq!(order, vec!["rk-pos0", "rk-pos2", "rk-nopos"]);
2078 }
2079
2080 #[test]
2081 fn saved_sort_newest_first_by_created_at() {
2082 let mut saved: Vec<(String, Saved)> = parse_all(json!({
2083 "records": [
2084 {
2085 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk-old",
2086 "value": { "url": "https://e.example/1", "createdAt": "2026-07-10T00:00:00.000Z" }
2087 },
2088 {
2089 "uri": "at://did:plc:x/community.lexicon.rss.saved/rk-new",
2090 "value": { "url": "https://e.example/2", "createdAt": "2026-07-12T00:00:00.000Z" }
2091 }
2092 ]
2093 }));
2094 saved.sort_by(|(a_key, a), (b_key, b)| {
2095 b.created_at
2096 .cmp(&a.created_at)
2097 .then_with(|| a_key.cmp(b_key))
2098 });
2099 assert_eq!(saved[0].0, "rk-new");
2100 assert_eq!(saved[1].0, "rk-old");
2101 }
2102
2103 // -- reader-facing CRUD: bulk applyWrites shape (OPML import) ------------
2104
2105 #[test]
2106 fn bulk_subscription_creates_render_sidecar_applywrites_ops() {
2107 // Mirror what `add_subscriptions_bulk` builds: one create op per feed,
2108 // each with a client-assigned rkey, in the sidecar's `applyWrites` shape.
2109 let subs = [
2110 Subscription::new("https://a.example/feed", "2026-07-12T00:00:00.000Z"),
2111 Subscription::new("https://b.example/feed", "2026-07-12T00:00:00.000Z"),
2112 ];
2113 let mut gen = TidGenerator::new();
2114 let ops: Vec<Value> = subs
2115 .iter()
2116 .map(|sub| {
2117 WriteOp::Create {
2118 collection: lexicon::nsid::SUBSCRIPTION.to_string(),
2119 rkey: Some(gen.next()),
2120 value: serde_json::to_value(sub).expect("value"),
2121 }
2122 .to_sidecar_json()
2123 })
2124 .collect();
2125
2126 assert_eq!(ops.len(), 2);
2127 for op in &ops {
2128 assert_eq!(op["action"], json!("create"));
2129 assert_eq!(
2130 op["collection"],
2131 json!("community.lexicon.rss.subscription")
2132 );
2133 assert!(op["rkey"].is_string(), "bulk import pins client-side rkeys");
2134 assert_eq!(
2135 op["value"]["$type"],
2136 json!("community.lexicon.rss.subscription")
2137 );
2138 }
2139 // Distinct, ascending rkeys keep OPML order deterministic.
2140 let k0 = ops[0]["rkey"].as_str().unwrap();
2141 let k1 = ops[1]["rkey"].as_str().unwrap();
2142 assert!(k0 < k1, "bulk rkeys must sort in input order ({k0} < {k1})");
2143 }
2144
2145 // -- TID rkeys ----------------------------------------------------------
2146
2147 #[test]
2148 fn tid_rkeys_are_13_char_s32_and_monotonic() {
2149 let mut gen = TidGenerator::new();
2150 let mut prev: Option<String> = None;
2151 for _ in 0..1000 {
2152 let tid = gen.next();
2153 assert_eq!(tid.len(), 13, "a TID is 13 s32 chars");
2154 assert!(
2155 tid.bytes().all(|b| S32_ALPHABET.contains(&b)),
2156 "TID {tid} uses only the s32 alphabet"
2157 );
2158 if let Some(p) = &prev {
2159 assert!(*p < tid, "TIDs must be strictly increasing ({p} < {tid})");
2160 }
2161 prev = Some(tid);
2162 }
2163 }
2164
2165 #[test]
2166 fn tid_rkeys_are_valid_atproto_record_keys() {
2167 // atproto rkey charset: [A-Za-z0-9._~:-], length 1..=512, not "."/"..".
2168 let mut gen = TidGenerator::new();
2169 let tid = gen.next();
2170 assert!(!tid.is_empty() && tid.len() <= 512);
2171 assert!(tid != "." && tid != "..");
2172 assert!(tid
2173 .bytes()
2174 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b':' | b'-')));
2175 }
2176
2177 #[test]
2178 fn s32_encoding_is_ascending_for_ascending_values() {
2179 // The whole point of s32: numeric order == lexicographic string order.
2180 assert!(encode_s32_tid(1) < encode_s32_tid(2));
2181 assert!(encode_s32_tid(31) < encode_s32_tid(32));
2182 assert!(encode_s32_tid(1_000_000) < encode_s32_tid(1_000_001));
2183 // Ordering holds all the way to the largest real TID value (a 53-bit
2184 // microsecond timestamp shifted into bits 63..10, plus the clock id).
2185 let max_tid = (0x001f_ffff_ffff_ffffu64 << 10) | 0x3ff;
2186 assert!(encode_s32_tid(max_tid - 1) < encode_s32_tid(max_tid));
2187 }
2188}