Skip to main content

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}