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_secret: Arc<str>,
823}
824
825/// The `{did, handle}` a session-id resolves to (the sidecar's
826/// `/internal/session/:id` body).
827#[derive(Debug, Clone, Deserialize)]
828pub struct SidecarSession {
829    /// The account DID that logged in.
830    pub did: String,
831    /// The account handle at login time.
832    #[serde(default)]
833    pub handle: Option<String>,
834}
835
836/// The sidecar's `/internal/revoke` response body:
837/// `{ ok:true, did, revoked, hadSession }`.
838#[derive(Debug, Clone, Deserialize)]
839pub struct RevokeResult {
840    /// The DID that was revoked.
841    #[serde(default)]
842    pub did: String,
843    /// Whether the OAuth token revocation at the PDS succeeded. `false` means
844    /// the local rows were still purged (best-effort), but the PDS-side tokens
845    /// may not have been invalidated (network failure).
846    #[serde(default)]
847    pub revoked: bool,
848    /// Whether the sidecar actually had a stored session for the DID.
849    #[serde(default, rename = "hadSession")]
850    pub had_session: bool,
851}
852
853/// The action verbs the sidecar's `/internal/repo` endpoint dispatches on.
854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855pub enum RepoAction {
856    /// `com.atproto.repo.listRecords`.
857    List,
858    /// `com.atproto.repo.createRecord`.
859    Create,
860    /// `com.atproto.repo.putRecord`.
861    Put,
862    /// `com.atproto.repo.deleteRecord`.
863    Delete,
864    /// `com.atproto.repo.applyWrites` (batch).
865    ApplyWrites,
866}
867
868impl RepoAction {
869    fn as_str(self) -> &'static str {
870        match self {
871            RepoAction::List => "list",
872            RepoAction::Create => "create",
873            RepoAction::Put => "put",
874            RepoAction::Delete => "delete",
875            RepoAction::ApplyWrites => "applyWrites",
876        }
877    }
878}
879
880/// The `/internal/repo` success envelope: `{ ok:true, data:<raw XRPC JSON> }`.
881#[derive(Debug, Deserialize)]
882struct RepoOk {
883    #[serde(default)]
884    data: Value,
885}
886
887/// The `/internal/repo` error envelope: `{ ok:false, error, message, status? }`.
888#[derive(Debug, Deserialize)]
889struct RepoErr {
890    #[serde(default)]
891    error: Option<String>,
892    #[serde(default)]
893    message: Option<String>,
894    #[serde(default)]
895    status: Option<u16>,
896}
897
898impl SidecarClient {
899    /// Build a sidecar client from the shared [`reqwest::Client`] and the
900    /// resolved base URL + internal secret (from [`crate::config::SidecarConfig`]).
901    pub fn new(
902        http: Client,
903        public_url: impl Into<String>,
904        internal_secret: impl Into<String>,
905    ) -> Self {
906        Self {
907            http,
908            public_url: Arc::from(public_url.into().trim_end_matches('/')),
909            internal_secret: Arc::from(internal_secret.into()),
910        }
911    }
912
913    /// The sidecar's public `/login` URL for a handle, round-tripping an opaque
914    /// `return` value through OAuth state (used to bounce the browser back to a
915    /// specific place after login). The browser is redirected here.
916    pub fn login_url(&self, handle: &str, return_to: Option<&str>) -> String {
917        let mut url = format!("{}/login?handle={}", self.public_url, urlencode(handle));
918        if let Some(r) = return_to {
919            url.push_str(&format!("&return={}", urlencode(r)));
920        }
921        url
922    }
923
924    /// Resolve a one-shot `session_id` (from the sidecar's post-OAuth redirect)
925    /// to the `{did, handle}` that logged in. `Ok(None)` on `404 SessionNotFound`.
926    pub async fn resolve_session(&self, session_id: &str) -> Result<Option<SidecarSession>> {
927        let url = format!(
928            "{}/internal/session/{}",
929            self.public_url,
930            urlencode(session_id)
931        );
932        let resp = self
933            .http
934            .get(&url)
935            .header("X-Internal-Secret", self.internal_secret.as_ref())
936            .send()
937            .await?;
938        if resp.status() == StatusCode::NOT_FOUND {
939            return Ok(None);
940        }
941        if !resp.status().is_success() {
942            return Err(xrpc_error_from(resp).await.into());
943        }
944        let session: SidecarSession = resp
945            .json()
946            .await
947            .context("parsing /internal/session response")?;
948        Ok(Some(session))
949    }
950
951    /// Revoke a DID's OAuth session at the sidecar: `POST /internal/revoke`.
952    ///
953    /// This revokes the refresh + access tokens at the PDS **and** purges the
954    /// sidecar's stored `oauth_session` + `app_session` rows for the DID. It is
955    /// idempotent — revoking a DID with no live session returns
956    /// `had_session: false`. Called on `/logout` (so the cookie clear isn't the
957    /// only thing that ends the session) and on `/account/delete`.
958    pub async fn revoke_session(&self, did: &str) -> Result<RevokeResult> {
959        let url = format!("{}/internal/revoke", self.public_url);
960        let resp = self
961            .http
962            .post(&url)
963            .header("X-Internal-Secret", self.internal_secret.as_ref())
964            .json(&json!({ "did": did }))
965            .send()
966            .await?;
967        if !resp.status().is_success() {
968            return Err(xrpc_error_from(resp).await.into());
969        }
970        let result: RevokeResult = resp
971            .json()
972            .await
973            .context("parsing /internal/revoke response")?;
974        Ok(result)
975    }
976
977    /// POST one op to `/internal/repo` and return the raw XRPC `data` payload.
978    ///
979    /// `body` must already carry `did` + `action` + the action's required fields
980    /// (the typed wrappers below build these). Maps the sidecar's error envelope
981    /// to [`AtProtoError`]: `404 SessionNotFound` → `Xrpc{error:"SessionNotFound"}`
982    /// so callers can treat it as "re-login required".
983    async fn repo(&self, body: Value) -> Result<Value> {
984        let url = format!("{}/internal/repo", self.public_url);
985        let resp = self
986            .http
987            .post(&url)
988            .header("X-Internal-Secret", self.internal_secret.as_ref())
989            .json(&body)
990            .send()
991            .await?;
992        let status = resp.status();
993        if status.is_success() {
994            let ok: RepoOk = resp
995                .json()
996                .await
997                .context("parsing /internal/repo ok body")?;
998            return Ok(ok.data);
999        }
1000        // Error path: parse the sidecar's `{ok:false,error,message,status}` shape.
1001        let err: RepoErr = resp.json().await.unwrap_or(RepoErr {
1002            error: None,
1003            message: None,
1004            status: None,
1005        });
1006        let mapped = err
1007            .status
1008            .and_then(|s| StatusCode::from_u16(s).ok())
1009            .unwrap_or(status);
1010        Err(AtProtoError::Xrpc {
1011            status: mapped,
1012            error: err.error.unwrap_or_else(|| "Unknown".to_string()),
1013            message: err.message,
1014        }
1015        .into())
1016    }
1017
1018    // -- raw com.atproto.repo.* over the sidecar -----------------------------
1019
1020    /// `list` — one page of a collection's records for `did`.
1021    pub async fn list_records(
1022        &self,
1023        did: &str,
1024        collection: &str,
1025        limit: Option<u32>,
1026        cursor: Option<&str>,
1027    ) -> Result<ListRecordsResponse> {
1028        let mut body = json!({
1029            "did": did,
1030            "action": RepoAction::List.as_str(),
1031            "collection": collection,
1032        });
1033        if let Some(limit) = limit {
1034            body["limit"] = json!(limit);
1035        }
1036        if let Some(cursor) = cursor {
1037            body["cursor"] = json!(cursor);
1038        }
1039        let data = self.repo(body).await?;
1040        serde_json::from_value(data).context("parsing sidecar listRecords data")
1041    }
1042
1043    /// Page through **all** records in a collection for `did`.
1044    pub async fn list_all_records(&self, did: &str, collection: &str) -> Result<Vec<RecordEntry>> {
1045        let mut out = Vec::new();
1046        let mut cursor: Option<String> = None;
1047        loop {
1048            let page = self
1049                .list_records(did, collection, Some(100), cursor.as_deref())
1050                .await?;
1051            let got = page.records.len();
1052            out.extend(page.records);
1053            match page.cursor {
1054                Some(next) if got > 0 => cursor = Some(next),
1055                _ => break,
1056            }
1057        }
1058        Ok(out)
1059    }
1060
1061    /// `create` — create a record (server-assigned rkey). Returns its strong ref.
1062    pub async fn create_record<T: Serialize>(
1063        &self,
1064        did: &str,
1065        collection: &str,
1066        record: &T,
1067    ) -> Result<WriteResult> {
1068        let body = json!({
1069            "did": did,
1070            "action": RepoAction::Create.as_str(),
1071            "collection": collection,
1072            "record": record,
1073        });
1074        let data = self.repo(body).await?;
1075        serde_json::from_value(data).context("parsing sidecar createRecord data")
1076    }
1077
1078    /// `put` — upsert a record at a known rkey. Returns its strong ref.
1079    pub async fn put_record<T: Serialize>(
1080        &self,
1081        did: &str,
1082        collection: &str,
1083        rkey: &str,
1084        record: &T,
1085    ) -> Result<WriteResult> {
1086        let body = json!({
1087            "did": did,
1088            "action": RepoAction::Put.as_str(),
1089            "collection": collection,
1090            "rkey": rkey,
1091            "record": record,
1092        });
1093        let data = self.repo(body).await?;
1094        serde_json::from_value(data).context("parsing sidecar putRecord data")
1095    }
1096
1097    /// `delete` — delete a record by collection + rkey.
1098    pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<()> {
1099        let body = json!({
1100            "did": did,
1101            "action": RepoAction::Delete.as_str(),
1102            "collection": collection,
1103            "rkey": rkey,
1104        });
1105        self.repo(body).await?;
1106        Ok(())
1107    }
1108
1109    /// `applyWrites` — a batch of create/update/delete ops in one round-trip.
1110    pub async fn apply_writes(&self, did: &str, writes: &[WriteOp]) -> Result<()> {
1111        if writes.is_empty() {
1112            return Ok(());
1113        }
1114        let ops: Vec<Value> = writes.iter().map(WriteOp::to_sidecar_json).collect();
1115        let body = json!({
1116            "did": did,
1117            "action": RepoAction::ApplyWrites.as_str(),
1118            "writes": ops,
1119        });
1120        self.repo(body).await?;
1121        Ok(())
1122    }
1123
1124    // -- typed lexicon wrappers (mirror the old PdsClient surface) ------------
1125
1126    /// List every [`Subscription`] record in `did`'s repo (paged fully).
1127    pub async fn list_subscriptions(&self, did: &str) -> Result<Vec<(String, Subscription)>> {
1128        self.list_typed(did, lexicon::nsid::SUBSCRIPTION).await
1129    }
1130
1131    /// Create a [`Subscription`] record (subscribe to a feed).
1132    pub async fn create_subscription(&self, did: &str, sub: &Subscription) -> Result<WriteResult> {
1133        self.create_record(did, lexicon::nsid::SUBSCRIPTION, sub)
1134            .await
1135    }
1136
1137    /// Delete a [`Subscription`] record by rkey (unsubscribe).
1138    pub async fn delete_subscription(&self, did: &str, rkey: &str) -> Result<()> {
1139        self.delete_record(did, lexicon::nsid::SUBSCRIPTION, rkey)
1140            .await
1141    }
1142
1143    /// Batch-create many [`Subscription`] records in one `applyWrites` — the OPML
1144    /// import path (one create op per feed, server-assigned rkeys).
1145    pub async fn create_subscriptions_batch(&self, did: &str, subs: &[Subscription]) -> Result<()> {
1146        let writes: Vec<WriteOp> = subs
1147            .iter()
1148            .map(|sub| {
1149                Ok(WriteOp::Create {
1150                    collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1151                    rkey: None,
1152                    value: serde_json::to_value(sub)?,
1153                })
1154            })
1155            .collect::<Result<_>>()?;
1156        self.apply_writes(did, &writes).await
1157    }
1158
1159    /// List every [`Folder`] record in `did`'s repo.
1160    pub async fn list_folders(&self, did: &str) -> Result<Vec<(String, Folder)>> {
1161        self.list_typed(did, lexicon::nsid::FOLDER).await
1162    }
1163
1164    /// List every [`Saved`] record in `did`'s repo.
1165    pub async fn list_saved(&self, did: &str) -> Result<Vec<(String, Saved)>> {
1166        self.list_typed(did, lexicon::nsid::SAVED).await
1167    }
1168
1169    /// List every [`ReadState`] cursor in `did`'s repo (the read side a
1170    /// login-time read-state merge would consume).
1171    pub async fn list_read_states(&self, did: &str) -> Result<Vec<(String, ReadState)>> {
1172        self.list_typed(did, lexicon::nsid::READ_STATE).await
1173    }
1174
1175    /// Upsert a single [`ReadState`] cursor at its feed-derived rkey.
1176    pub async fn put_read_state(
1177        &self,
1178        did: &str,
1179        rkey: &str,
1180        state: &ReadState,
1181    ) -> Result<WriteResult> {
1182        self.put_record(did, lexicon::nsid::READ_STATE, rkey, state)
1183            .await
1184    }
1185
1186    /// Batch-flush many dirty [`ReadState`] cursors in one `applyWrites` call.
1187    ///
1188    /// Each `(rkey, state, pds_created)` becomes a `create` op at the feed-derived
1189    /// rkey when the record does NOT yet exist (`pds_created == false`), and an
1190    /// `update` op when it does. This is what makes the FIRST flush of a feed
1191    /// succeed: `applyWrites#update` errors on a record that does not pre-exist,
1192    /// and `applyWrites` is atomic per-repo, so a single not-yet-created cursor
1193    /// would otherwise drop the whole DID batch. Both kinds ride the SAME
1194    /// `applyWrites` batch so batching is preserved.
1195    pub async fn flush_read_states(
1196        &self,
1197        did: &str,
1198        cursors: &[(String, ReadState, bool)],
1199    ) -> Result<()> {
1200        if cursors.is_empty() {
1201            return Ok(());
1202        }
1203        let writes = read_state_write_ops(cursors)?;
1204        self.apply_writes(did, &writes).await
1205    }
1206
1207    // -- reader-facing record CRUD (the surface the web layer calls) ----------
1208    //
1209    // These are the typed convenience methods `web.rs` uses to manage a user's
1210    // feeds/folders/saved items *as records in their PDS*. They mirror the
1211    // create/list surface above but use the reader vocabulary
1212    // (add/remove/rename) and, for the `add_*` verbs, return the server-assigned
1213    // rkey so the caller can address the new record without a re-list. Ordering
1214    // is made deterministic where it matters (see [`list_subscriptions_sorted`]
1215    // etc.) so the server-rendered HTML is stable between reads.
1216
1217    // -- subscriptions -------------------------------------------------------
1218
1219    /// Add a subscription (subscribe to a feed) — `createRecord`, server-assigned
1220    /// `tid` rkey. Returns the new record's **rkey** so the web layer can offer
1221    /// unsubscribe/rename immediately.
1222    pub async fn add_subscription(&self, did: &str, sub: &Subscription) -> Result<String> {
1223        Ok(self.create_subscription(did, sub).await?.into_rkey())
1224    }
1225
1226    /// Remove a subscription (unsubscribe) by rkey — `deleteRecord`. Alias of
1227    /// [`delete_subscription`](Self::delete_subscription) in the reader vocabulary.
1228    pub async fn remove_subscription(&self, did: &str, rkey: &str) -> Result<()> {
1229        self.delete_subscription(did, rkey).await
1230    }
1231
1232    /// Update / rename a subscription in place at a known rkey — `putRecord`.
1233    ///
1234    /// The whole record is replaced (retitle, move to a folder, change the
1235    /// fetch hint …). Upsert semantics: it also creates the record if the rkey
1236    /// is somehow absent, so it is safe as a general "write this exact record".
1237    pub async fn update_subscription(
1238        &self,
1239        did: &str,
1240        rkey: &str,
1241        sub: &Subscription,
1242    ) -> Result<WriteResult> {
1243        self.put_record(did, lexicon::nsid::SUBSCRIPTION, rkey, sub)
1244            .await
1245    }
1246
1247    /// List every subscription, **sorted deterministically** — by display title
1248    /// (case-insensitive), then feed URL, then rkey as the final tiebreaker — so
1249    /// the rendered feed list is stable across reads regardless of PDS return
1250    /// order. Untitled feeds sort by their URL.
1251    pub async fn list_subscriptions_sorted(
1252        &self,
1253        did: &str,
1254    ) -> Result<Vec<(String, Subscription)>> {
1255        let mut subs = self.list_subscriptions(did).await?;
1256        subs.sort_by(|(a_key, a), (b_key, b)| {
1257            let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
1258            let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
1259            a_title
1260                .cmp(&b_title)
1261                .then_with(|| a.url.cmp(&b.url))
1262                .then_with(|| a_key.cmp(b_key))
1263        });
1264        Ok(subs)
1265    }
1266
1267    /// Batch-add many subscriptions in one `applyWrites` — the OPML-import path.
1268    ///
1269    /// Each feed becomes one `create` op. Client-side monotonic [`tid`](tid)
1270    /// rkeys are assigned so the batch is deterministic and the imported feeds
1271    /// keep OPML order (server-assigned tids would also be monotonic, but pinning
1272    /// them here makes the whole import reproducible and testable offline).
1273    /// Returns the assigned rkeys in input order.
1274    pub async fn add_subscriptions_bulk(
1275        &self,
1276        did: &str,
1277        subs: &[Subscription],
1278    ) -> Result<Vec<String>> {
1279        let mut gen = TidGenerator::new();
1280        let mut rkeys = Vec::with_capacity(subs.len());
1281        let mut writes = Vec::with_capacity(subs.len());
1282        for sub in subs {
1283            let rkey = gen.next();
1284            writes.push(WriteOp::Create {
1285                collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1286                rkey: Some(rkey.clone()),
1287                value: serde_json::to_value(sub)?,
1288            });
1289            rkeys.push(rkey);
1290        }
1291        self.apply_writes(did, &writes).await?;
1292        Ok(rkeys)
1293    }
1294
1295    // -- folders -------------------------------------------------------------
1296
1297    /// Add a folder — `createRecord`, server-assigned `tid` rkey. Returns the
1298    /// new folder's rkey (subscriptions reference it by its `at://` URI).
1299    pub async fn add_folder(&self, did: &str, folder: &Folder) -> Result<String> {
1300        Ok(self
1301            .create_record(did, lexicon::nsid::FOLDER, folder)
1302            .await?
1303            .into_rkey())
1304    }
1305
1306    /// Remove a folder by rkey — `deleteRecord`. (Subscriptions referencing it
1307    /// are left untouched; a dangling `folder` ref reads as "unfiled".)
1308    pub async fn remove_folder(&self, did: &str, rkey: &str) -> Result<()> {
1309        self.delete_record(did, lexicon::nsid::FOLDER, rkey).await
1310    }
1311
1312    /// Rename / update a folder in place at a known rkey — `putRecord`
1313    /// (rename, or change its `position` sort hint).
1314    pub async fn rename_folder(
1315        &self,
1316        did: &str,
1317        rkey: &str,
1318        folder: &Folder,
1319    ) -> Result<WriteResult> {
1320        self.put_record(did, lexicon::nsid::FOLDER, rkey, folder)
1321            .await
1322    }
1323
1324    /// List every folder, **sorted deterministically** — by `position` (the
1325    /// lexicon's sort hint; unset sorts last), then name (case-insensitive),
1326    /// then rkey — so the sidebar order is stable.
1327    pub async fn list_folders_sorted(&self, did: &str) -> Result<Vec<(String, Folder)>> {
1328        let mut folders = self.list_folders(did).await?;
1329        folders.sort_by(|(a_key, a), (b_key, b)| {
1330            a.position
1331                .unwrap_or(u64::MAX)
1332                .cmp(&b.position.unwrap_or(u64::MAX))
1333                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1334                .then_with(|| a_key.cmp(b_key))
1335        });
1336        Ok(folders)
1337    }
1338
1339    // -- saved / starred -----------------------------------------------------
1340
1341    /// Add a saved (starred / save-for-later) entry — `createRecord`,
1342    /// server-assigned `tid` rkey. Returns the new record's rkey.
1343    pub async fn add_saved(&self, did: &str, saved: &Saved) -> Result<String> {
1344        Ok(self
1345            .create_record(did, lexicon::nsid::SAVED, saved)
1346            .await?
1347            .into_rkey())
1348    }
1349
1350    /// Remove a saved entry by rkey — `deleteRecord` (un-star).
1351    pub async fn remove_saved(&self, did: &str, rkey: &str) -> Result<()> {
1352        self.delete_record(did, lexicon::nsid::SAVED, rkey).await
1353    }
1354
1355    /// List every saved entry, **sorted deterministically** — newest first by
1356    /// `createdAt` (RFC-3339 sorts lexicographically), then rkey — so the
1357    /// "saved for later" list reads most-recent-first and is stable.
1358    pub async fn list_saved_sorted(&self, did: &str) -> Result<Vec<(String, Saved)>> {
1359        let mut saved = self.list_saved(did).await?;
1360        saved.sort_by(|(a_key, a), (b_key, b)| {
1361            b.created_at
1362                .cmp(&a.created_at)
1363                .then_with(|| a_key.cmp(b_key))
1364        });
1365        Ok(saved)
1366    }
1367
1368    /// List a collection for `did` and parse each record's value into `T`,
1369    /// pairing it with its rkey. Unparseable records are skipped with a warning
1370    /// (forward-compat).
1371    async fn list_typed<T: DeserializeOwned>(
1372        &self,
1373        did: &str,
1374        collection: &str,
1375    ) -> Result<Vec<(String, T)>> {
1376        let records = self.list_all_records(did, collection).await?;
1377        let mut out = Vec::with_capacity(records.len());
1378        for rec in records {
1379            let rkey = rec.rkey().unwrap_or_default().to_string();
1380            match rec.parse::<T>() {
1381                Ok(value) => out.push((rkey, value)),
1382                Err(e) => tracing::warn!(
1383                    collection,
1384                    uri = %rec.uri,
1385                    error = %e,
1386                    "skipping unparseable record in collection"
1387                ),
1388            }
1389        }
1390        Ok(out)
1391    }
1392}
1393
1394// ---------------------------------------------------------------------------
1395// applyWrites operations
1396// ---------------------------------------------------------------------------
1397
1398/// Build the `applyWrites` ops for a batch of dirty read-state cursors.
1399///
1400/// Each `(rkey, state, pds_created)` becomes a `#create` op (at the stable
1401/// feed-derived rkey) when the PDS record does NOT yet exist, and a `#update`
1402/// when it does. This is the crux of the first-flush fix: an `#update` on a
1403/// missing record errors, and `applyWrites` is atomic per-repo, so a single
1404/// not-yet-created cursor in the batch would drop the whole DID's flush. Emitting
1405/// a `create` for those makes a feed's first flush succeed while keeping every
1406/// op in ONE batch. Shared by both the sidecar and direct-PDS flush paths.
1407fn read_state_write_ops(cursors: &[(String, ReadState, bool)]) -> Result<Vec<WriteOp>> {
1408    cursors
1409        .iter()
1410        .map(|(rkey, state, pds_created)| {
1411            let value = serde_json::to_value(state)?;
1412            Ok(if *pds_created {
1413                WriteOp::Update {
1414                    collection: lexicon::nsid::READ_STATE.to_string(),
1415                    rkey: rkey.clone(),
1416                    value,
1417                }
1418            } else {
1419                WriteOp::Create {
1420                    collection: lexicon::nsid::READ_STATE.to_string(),
1421                    rkey: Some(rkey.clone()),
1422                    value,
1423                }
1424            })
1425        })
1426        .collect()
1427}
1428
1429/// One operation in a [`PdsClient::apply_writes`] batch.
1430///
1431/// Maps to the `com.atproto.repo.applyWrites` union of
1432/// `#create` / `#update` / `#delete`.
1433#[derive(Debug, Clone)]
1434pub enum WriteOp {
1435    /// Create a record (server-assigned rkey unless `rkey` is given).
1436    Create {
1437        /// The collection NSID.
1438        collection: String,
1439        /// Optional explicit rkey (`None` → server assigns a tid).
1440        rkey: Option<String>,
1441        /// The record body.
1442        value: Value,
1443    },
1444    /// Upsert a record at a known rkey (the read-state cursor case).
1445    Update {
1446        /// The collection NSID.
1447        collection: String,
1448        /// The rkey to write at.
1449        rkey: String,
1450        /// The record body.
1451        value: Value,
1452    },
1453    /// Delete a record by collection + rkey.
1454    Delete {
1455        /// The collection NSID.
1456        collection: String,
1457        /// The rkey to delete.
1458        rkey: String,
1459    },
1460}
1461
1462impl WriteOp {
1463    /// Render this op as the tagged JSON `com.atproto.repo.applyWrites` expects.
1464    fn to_json(&self) -> Value {
1465        match self {
1466            WriteOp::Create {
1467                collection,
1468                rkey,
1469                value,
1470            } => {
1471                let mut op = json!({
1472                    "$type": "com.atproto.repo.applyWrites#create",
1473                    "collection": collection,
1474                    "value": value,
1475                });
1476                if let Some(rkey) = rkey {
1477                    op["rkey"] = json!(rkey);
1478                }
1479                op
1480            }
1481            WriteOp::Update {
1482                collection,
1483                rkey,
1484                value,
1485            } => json!({
1486                "$type": "com.atproto.repo.applyWrites#update",
1487                "collection": collection,
1488                "rkey": rkey,
1489                "value": value,
1490            }),
1491            WriteOp::Delete { collection, rkey } => json!({
1492                "$type": "com.atproto.repo.applyWrites#delete",
1493                "collection": collection,
1494                "rkey": rkey,
1495            }),
1496        }
1497    }
1498
1499    /// Render this op in the shape the OAuth sidecar's `/internal/repo`
1500    /// `applyWrites` expects: `{action, collection, rkey?, value?}` (the sidecar
1501    /// maps `action` → the `com.atproto.repo.applyWrites#<kind>` union member).
1502    fn to_sidecar_json(&self) -> Value {
1503        match self {
1504            WriteOp::Create {
1505                collection,
1506                rkey,
1507                value,
1508            } => {
1509                let mut op = json!({
1510                    "action": "create",
1511                    "collection": collection,
1512                    "value": value,
1513                });
1514                if let Some(rkey) = rkey {
1515                    op["rkey"] = json!(rkey);
1516                }
1517                op
1518            }
1519            WriteOp::Update {
1520                collection,
1521                rkey,
1522                value,
1523            } => json!({
1524                "action": "update",
1525                "collection": collection,
1526                "rkey": rkey,
1527                "value": value,
1528            }),
1529            WriteOp::Delete { collection, rkey } => json!({
1530                "action": "delete",
1531                "collection": collection,
1532                "rkey": rkey,
1533            }),
1534        }
1535    }
1536}
1537
1538// ---------------------------------------------------------------------------
1539// TID rkeys (client-assigned, sortable, deterministic within a batch)
1540// ---------------------------------------------------------------------------
1541
1542/// The atproto base32-sortable alphabet (`s32`) — the digits/letters, minus the
1543/// ambiguous set, in **ascending** order so a bytewise string compare of two
1544/// TIDs matches their timestamp order.
1545const S32_ALPHABET: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
1546
1547/// A monotonic generator of atproto **TID** record keys.
1548///
1549/// A TID is a 13-char `s32`-encoded 64-bit integer: a 53-bit microsecond
1550/// timestamp in the high bits and a 10-bit "clock id" in the low bits (the top
1551/// bit is always 0). Encoded in the ascending `s32` alphabet, TIDs sort
1552/// lexicographically in creation order — which is exactly what we want for a
1553/// batched OPML import: assigning the rkeys ourselves keeps the imported feeds
1554/// in input order and makes [`add_subscriptions_bulk`](SidecarClient::add_subscriptions_bulk)
1555/// fully reproducible/testable without a live PDS.
1556///
1557/// Monotonicity within one generator is guaranteed by tracking the last value
1558/// and bumping to `last + 1` if the clock hasn't advanced — so a burst of
1559/// same-microsecond calls still yields strictly increasing, ordered rkeys.
1560struct TidGenerator {
1561    /// The last raw 64-bit TID value emitted (0 = none yet).
1562    last: u64,
1563    /// The low-10-bit clock id, randomized once per generator to avoid
1564    /// cross-instance collisions on the same microsecond.
1565    clock_id: u64,
1566}
1567
1568impl TidGenerator {
1569    /// A fresh generator with a per-instance clock id derived from the current
1570    /// nanosecond clock (no extra deps; uniqueness only needs to hold within a
1571    /// single import batch, and the timestamp bits carry the ordering).
1572    fn new() -> Self {
1573        let nanos = std::time::SystemTime::now()
1574            .duration_since(std::time::UNIX_EPOCH)
1575            .map(|d| d.subsec_nanos() as u64)
1576            .unwrap_or(0);
1577        Self {
1578            last: 0,
1579            clock_id: nanos & 0x3ff,
1580        }
1581    }
1582
1583    /// The next monotonic TID rkey (13 `s32` chars).
1584    fn next(&mut self) -> String {
1585        let micros = std::time::SystemTime::now()
1586            .duration_since(std::time::UNIX_EPOCH)
1587            .map(|d| d.as_micros() as u64)
1588            .unwrap_or(0);
1589        // Timestamp in bits 63..10 (top bit stays 0), clock id in bits 9..0.
1590        let mut raw = ((micros & 0x001f_ffff_ffff_ffff) << 10) | self.clock_id;
1591        if raw <= self.last {
1592            raw = self.last + 1;
1593        }
1594        self.last = raw;
1595        encode_s32_tid(raw)
1596    }
1597}
1598
1599/// Encode a 64-bit TID value as a 13-char big-endian `s32` string.
1600fn encode_s32_tid(mut v: u64) -> String {
1601    let mut buf = [0u8; 13];
1602    for slot in buf.iter_mut().rev() {
1603        *slot = S32_ALPHABET[(v & 0x1f) as usize];
1604        v >>= 5;
1605    }
1606    // 13 * 5 = 65 bits cover the 64-bit value; the leading char holds the top
1607    // (always-0) bit, so it is always the alphabet's first symbol.
1608    String::from_utf8(buf.to_vec()).unwrap_or_default()
1609}
1610
1611// ---------------------------------------------------------------------------
1612// XRPC error helper
1613// ---------------------------------------------------------------------------
1614
1615/// Minimal percent-encoding for a query-string component.
1616///
1617/// Encodes everything outside the RFC 3986 unreserved set, which covers the
1618/// values FeatherReader passes (DIDs like `did:plc:…`, NSIDs, opaque cursors,
1619/// handles) without pulling in the optional reqwest `url`/`query` feature.
1620fn urlencode(s: &str) -> String {
1621    let mut out = String::with_capacity(s.len());
1622    for b in s.bytes() {
1623        match b {
1624            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1625                out.push(b as char)
1626            }
1627            _ => out.push_str(&format!("%{b:02X}")),
1628        }
1629    }
1630    out
1631}
1632
1633/// The atproto XRPC error envelope body: `{"error": "...", "message": "..."}`.
1634#[derive(Debug, Deserialize)]
1635struct XrpcErrorBody {
1636    #[serde(default)]
1637    error: Option<String>,
1638    #[serde(default)]
1639    message: Option<String>,
1640}
1641
1642/// Consume a non-2xx response into a typed [`AtProtoError::Xrpc`], parsing the
1643/// atproto error envelope when present (falling back to `"Unknown"`).
1644async fn xrpc_error_from(resp: reqwest::Response) -> AtProtoError {
1645    let status = resp.status();
1646    let (error, message) = match resp.json::<XrpcErrorBody>().await {
1647        Ok(body) => (
1648            body.error.unwrap_or_else(|| "Unknown".to_string()),
1649            body.message,
1650        ),
1651        Err(_) => ("Unknown".to_string(), None),
1652    };
1653    AtProtoError::Xrpc {
1654        status,
1655        error,
1656        message,
1657    }
1658}
1659
1660// ---------------------------------------------------------------------------
1661// Tests — record (de)serialization against a repo listRecords response shape.
1662// No network.
1663// ---------------------------------------------------------------------------
1664
1665#[cfg(test)]
1666mod tests {
1667    use super::*;
1668
1669    /// A realistic `com.atproto.repo.listRecords` response for the subscription
1670    /// collection, as a PDS returns it — the envelope wraps each record in
1671    /// `{uri, cid, value}` and the record `value` carries its `$type`.
1672    fn subscription_list_json() -> Value {
1673        json!({
1674            "records": [
1675                {
1676                    "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0001",
1677                    "cid": "bafyreisubone",
1678                    "value": {
1679                        "$type": "community.lexicon.rss.subscription",
1680                        "url": "https://example.com/feed.xml",
1681                        "title": "Example Blog",
1682                        "siteUrl": "https://example.com/",
1683                        "fetchHint": "hourly",
1684                        "createdAt": "2026-07-12T00:00:00.000Z"
1685                    }
1686                },
1687                {
1688                    "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0002",
1689                    "cid": "bafyreisubtwo",
1690                    "value": {
1691                        "$type": "community.lexicon.rss.subscription",
1692                        "url": "https://blog.example.org/atom.xml",
1693                        "createdAt": "2026-07-11T12:00:00.000Z"
1694                    }
1695                }
1696            ],
1697            "cursor": "3ksub0002"
1698        })
1699    }
1700
1701    #[test]
1702    fn list_records_envelope_deserializes() {
1703        let resp: ListRecordsResponse =
1704            serde_json::from_value(subscription_list_json()).expect("envelope");
1705        assert_eq!(resp.records.len(), 2);
1706        assert_eq!(resp.cursor.as_deref(), Some("3ksub0002"));
1707        assert_eq!(resp.records[0].cid.as_deref(), Some("bafyreisubone"));
1708    }
1709
1710    #[test]
1711    fn record_entry_rkey_is_last_uri_segment() {
1712        let resp: ListRecordsResponse =
1713            serde_json::from_value(subscription_list_json()).expect("envelope");
1714        assert_eq!(resp.records[0].rkey(), Some("3ksub0001"));
1715        assert_eq!(resp.records[1].rkey(), Some("3ksub0002"));
1716    }
1717
1718    #[test]
1719    fn record_value_parses_into_lexicon_subscription() {
1720        let resp: ListRecordsResponse =
1721            serde_json::from_value(subscription_list_json()).expect("envelope");
1722
1723        let full: Subscription = resp.records[0].parse().expect("parse full sub");
1724        assert_eq!(full.r#type, lexicon::nsid::SUBSCRIPTION);
1725        assert_eq!(full.url, "https://example.com/feed.xml");
1726        assert_eq!(full.title.as_deref(), Some("Example Blog"));
1727        assert_eq!(full.site_url.as_deref(), Some("https://example.com/"));
1728        assert_eq!(full.fetch_hint, Some(lexicon::FetchHint::Hourly));
1729
1730        let minimal: Subscription = resp.records[1].parse().expect("parse minimal sub");
1731        assert_eq!(minimal.url, "https://blog.example.org/atom.xml");
1732        assert!(minimal.title.is_none());
1733    }
1734
1735    fn ssrf_test_client() -> Client {
1736        Client::builder()
1737            .user_agent(crate::USER_AGENT)
1738            .build()
1739            .unwrap()
1740    }
1741
1742    /// A hostile `did:web` whose host is the cloud-metadata address must be
1743    /// REFUSED before any request leaves the box — the DID-document fetch now
1744    /// routes through the SSRF guard (`guarded_get_no_privacy`), which rejects
1745    /// link-local / metadata targets.
1746    #[tokio::test]
1747    async fn resolve_did_web_blocks_metadata_host() {
1748        let client = ssrf_test_client();
1749        let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:169.254.169.254")
1750            .await
1751            .unwrap_err()
1752            .to_string();
1753        assert!(
1754            err.contains("forbidden") || err.contains("internal"),
1755            "expected an SSRF refusal, got: {err}"
1756        );
1757    }
1758
1759    /// A `did:web` pointing at loopback is likewise blocked (internal service
1760    /// reflection).
1761    #[tokio::test]
1762    async fn resolve_did_web_blocks_loopback_host() {
1763        let client = ssrf_test_client();
1764        let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:127.0.0.1")
1765            .await
1766            .unwrap_err()
1767            .to_string();
1768        assert!(
1769            err.contains("forbidden") || err.contains("internal"),
1770            "expected an SSRF refusal, got: {err}"
1771        );
1772    }
1773
1774    /// `resolve_handle` against a metadata/loopback resolver base is also guarded
1775    /// (the base can come from a prior hostile DID-doc resolution).
1776    #[tokio::test]
1777    async fn resolve_handle_blocks_metadata_resolver_base() {
1778        let client = ssrf_test_client();
1779        let err = resolve_handle(&client, "http://169.254.169.254", "alice.example.com")
1780            .await
1781            .unwrap_err()
1782            .to_string();
1783        assert!(
1784            err.contains("forbidden") || err.contains("internal"),
1785            "expected an SSRF refusal, got: {err}"
1786        );
1787    }
1788
1789    /// A resolved `serviceEndpoint` that targets an internal host is rejected at
1790    /// resolve time via [`crate::net::assert_public_target`], so it can never be
1791    /// handed to a raw XRPC client.
1792    #[tokio::test]
1793    async fn service_endpoint_internal_target_rejected() {
1794        assert!(crate::net::assert_public_target("http://169.254.169.254/")
1795            .await
1796            .is_err());
1797        assert!(crate::net::assert_public_target("http://127.0.0.1:3000/")
1798            .await
1799            .is_err());
1800        // A public endpoint literal passes.
1801        assert!(crate::net::assert_public_target("https://1.1.1.1/")
1802            .await
1803            .is_ok());
1804    }
1805
1806    #[test]
1807    fn write_result_deserializes() {
1808        let wr: WriteResult = serde_json::from_value(json!({
1809            "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
1810            "cid": "bafyreinew"
1811        }))
1812        .expect("write result");
1813        assert!(wr.uri.ends_with("3ksubnew"));
1814        assert_eq!(wr.cid.as_deref(), Some("bafyreinew"));
1815    }
1816
1817    #[test]
1818    fn did_document_finds_pds_endpoint() {
1819        let doc: DidDocument = serde_json::from_value(json!({
1820            "id": "did:plc:abc123",
1821            "service": [
1822                {
1823                    "id": "#atproto_pds",
1824                    "type": "AtprotoPersonalDataServer",
1825                    "serviceEndpoint": "https://pds.example.com/"
1826                }
1827            ]
1828        }))
1829        .expect("did doc");
1830        assert_eq!(
1831            doc.pds_endpoint().as_deref(),
1832            Some("https://pds.example.com")
1833        );
1834    }
1835
1836    #[test]
1837    fn did_document_without_pds_yields_none() {
1838        let doc: DidDocument = serde_json::from_value(json!({
1839            "id": "did:plc:abc123",
1840            "service": []
1841        }))
1842        .expect("did doc");
1843        assert!(doc.pds_endpoint().is_none());
1844    }
1845
1846    #[test]
1847    fn session_auth_deserializes_create_session_shape() {
1848        let session: SessionAuth = serde_json::from_value(json!({
1849            "did": "did:plc:abc123",
1850            "handle": "alice.example.com",
1851            "accessJwt": "eyJh...access",
1852            "refreshJwt": "eyJh...refresh"
1853        }))
1854        .expect("session");
1855        assert_eq!(session.did, "did:plc:abc123");
1856        assert_eq!(session.handle.as_deref(), Some("alice.example.com"));
1857        let auth = Auth::Session(session);
1858        assert_eq!(auth.bearer().expect("bearer"), "eyJh...access");
1859    }
1860
1861    #[test]
1862    fn oauth_variant_carries_no_direct_bearer() {
1863        let auth = Auth::Oauth(OauthPlaceholder::default());
1864        assert!(
1865            auth.bearer().is_err(),
1866            "Auth::Oauth carries no direct bearer — the sidecar owns the OAuth path"
1867        );
1868    }
1869
1870    #[test]
1871    fn apply_writes_ops_render_tagged_union() {
1872        let create = WriteOp::Create {
1873            collection: lexicon::nsid::SUBSCRIPTION.to_string(),
1874            rkey: None,
1875            value: json!({"url": "https://example.com/feed.xml"}),
1876        };
1877        let update = WriteOp::Update {
1878            collection: lexicon::nsid::READ_STATE.to_string(),
1879            rkey: "feedhash01".to_string(),
1880            value: json!({"feedUrl": "https://example.com/feed.xml"}),
1881        };
1882        let delete = WriteOp::Delete {
1883            collection: lexicon::nsid::SAVED.to_string(),
1884            rkey: "3ksaved01".to_string(),
1885        };
1886
1887        assert_eq!(
1888            create.to_json()["$type"],
1889            json!("com.atproto.repo.applyWrites#create")
1890        );
1891        // A create with no explicit rkey omits the field (server assigns a tid).
1892        assert!(create.to_json().get("rkey").is_none());
1893
1894        assert_eq!(
1895            update.to_json()["$type"],
1896            json!("com.atproto.repo.applyWrites#update")
1897        );
1898        assert_eq!(update.to_json()["rkey"], json!("feedhash01"));
1899
1900        assert_eq!(
1901            delete.to_json()["$type"],
1902            json!("com.atproto.repo.applyWrites#delete")
1903        );
1904        assert_eq!(delete.to_json()["rkey"], json!("3ksaved01"));
1905    }
1906
1907    #[test]
1908    fn read_state_flush_creates_first_then_updates() {
1909        // A cursor whose PDS record does NOT yet exist (pds_created = false) must
1910        // become a CREATE op at its stable rkey — NOT a bare update, which would
1911        // error on the missing record and (applyWrites being atomic per-repo) drop
1912        // the whole batch on a feed's first flush.
1913        let fresh = (
1914            "rs-fresh".to_string(),
1915            ReadState::new("https://a.example/feed.xml", None, "2026-07-12T00:00:00Z"),
1916            false,
1917        );
1918        // An already-created cursor updates in place.
1919        let existing = (
1920            "rs-existing".to_string(),
1921            ReadState::new(
1922                "https://b.example/feed.xml",
1923                Some("2026-07-11T00:00:00Z".to_string()),
1924                "2026-07-12T00:00:00Z",
1925            ),
1926            true,
1927        );
1928
1929        let ops = read_state_write_ops(&[fresh, existing]).expect("build ops");
1930        assert_eq!(ops.len(), 2);
1931
1932        // First op: a create carrying the stable rkey (put/create, not update).
1933        let create = ops[0].to_json();
1934        assert_eq!(
1935            create["$type"],
1936            json!("com.atproto.repo.applyWrites#create"),
1937            "first flush of a new feed must CREATE its readState record"
1938        );
1939        assert_eq!(create["rkey"], json!("rs-fresh"));
1940        // The created record omits readThrough (F1): backlog not implicitly read.
1941        assert!(create["value"].get("readThrough").is_none());
1942
1943        // Second op: an update for the already-created record.
1944        let update = ops[1].to_json();
1945        assert_eq!(
1946            update["$type"],
1947            json!("com.atproto.repo.applyWrites#update")
1948        );
1949        assert_eq!(update["rkey"], json!("rs-existing"));
1950
1951        // Both ride the SAME batch — batching is preserved.
1952        assert_eq!(ops.len(), 2);
1953    }
1954
1955    #[test]
1956    fn urlencode_escapes_did_colons_and_keeps_unreserved() {
1957        assert_eq!(urlencode("did:plc:abc123"), "did%3Aplc%3Aabc123");
1958        assert_eq!(
1959            urlencode("community.lexicon.rss.subscription"),
1960            "community.lexicon.rss.subscription"
1961        );
1962        assert_eq!(urlencode("a b&c"), "a%20b%26c");
1963    }
1964
1965    #[test]
1966    fn xrpc_record_not_found_is_detected() {
1967        let err = AtProtoError::Xrpc {
1968            status: StatusCode::BAD_REQUEST,
1969            error: "RecordNotFound".to_string(),
1970            message: Some("Could not locate record".to_string()),
1971        };
1972        assert!(err.is_record_not_found());
1973    }
1974
1975    // -- reader-facing CRUD: rkey extraction --------------------------------
1976
1977    #[test]
1978    fn write_result_extracts_rkey_from_uri() {
1979        let wr: WriteResult = serde_json::from_value(json!({
1980            "uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
1981            "cid": "bafyreinew"
1982        }))
1983        .expect("write result");
1984        assert_eq!(wr.rkey(), Some("3ksubnew"));
1985        assert_eq!(wr.into_rkey(), "3ksubnew");
1986    }
1987
1988    // -- reader-facing CRUD: deterministic sort orders ----------------------
1989    //
1990    // The `list_*_sorted` wrappers only add an ordering on top of the network
1991    // `list_*` read, so we exercise the *comparator* here on representative
1992    // data (parsed from a listRecords-shaped envelope) with no network.
1993
1994    fn parse_all<T: DeserializeOwned>(v: Value) -> Vec<(String, T)> {
1995        let resp: ListRecordsResponse = serde_json::from_value(v).expect("envelope");
1996        resp.records
1997            .into_iter()
1998            .map(|r| {
1999                let rkey = r.rkey().unwrap_or_default().to_string();
2000                (rkey, r.parse::<T>().expect("parse"))
2001            })
2002            .collect()
2003    }
2004
2005    fn sort_subscriptions(subs: &mut [(String, Subscription)]) {
2006        subs.sort_by(|(a_key, a), (b_key, b)| {
2007            let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
2008            let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
2009            a_title
2010                .cmp(&b_title)
2011                .then_with(|| a.url.cmp(&b.url))
2012                .then_with(|| a_key.cmp(b_key))
2013        });
2014    }
2015
2016    #[test]
2017    fn subscriptions_sort_by_title_then_url_then_rkey() {
2018        let mut subs: Vec<(String, Subscription)> = parse_all(json!({
2019            "records": [
2020                {
2021                    "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-zebra",
2022                    "value": { "url": "https://z.example/feed", "title": "Zebra News",
2023                               "createdAt": "2026-07-12T00:00:00.000Z" }
2024                },
2025                {
2026                    "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-untitled",
2027                    "value": { "url": "https://aaa.example/feed",
2028                               "createdAt": "2026-07-12T00:00:00.000Z" }
2029                },
2030                {
2031                    "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-apple",
2032                    "value": { "url": "https://apple.example/feed", "title": "apple blog",
2033                               "createdAt": "2026-07-12T00:00:00.000Z" }
2034                }
2035            ]
2036        }));
2037        sort_subscriptions(&mut subs);
2038        // Case-insensitive by the display key (title, or URL when untitled):
2039        // "apple blog" < "https://aaa.example/feed" < "zebra news".
2040        let order: Vec<&str> = subs.iter().map(|(k, _)| k.as_str()).collect();
2041        assert_eq!(order, vec!["rk-apple", "rk-untitled", "rk-zebra"]);
2042    }
2043
2044    #[test]
2045    fn folders_sort_by_position_then_name_then_rkey() {
2046        let mut folders: Vec<(String, Folder)> = parse_all(json!({
2047            "records": [
2048                {
2049                    "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-nopos",
2050                    "value": { "name": "Aardvark", "createdAt": "2026-07-12T00:00:00.000Z" }
2051                },
2052                {
2053                    "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos2",
2054                    "value": { "name": "Tech", "position": 2, "createdAt": "2026-07-12T00:00:00.000Z" }
2055                },
2056                {
2057                    "uri": "at://did:plc:x/community.lexicon.rss.folder/rk-pos0",
2058                    "value": { "name": "News", "position": 0, "createdAt": "2026-07-12T00:00:00.000Z" }
2059                }
2060            ]
2061        }));
2062        folders.sort_by(|(a_key, a), (b_key, b)| {
2063            a.position
2064                .unwrap_or(u64::MAX)
2065                .cmp(&b.position.unwrap_or(u64::MAX))
2066                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
2067                .then_with(|| a_key.cmp(b_key))
2068        });
2069        let order: Vec<&str> = folders.iter().map(|(k, _)| k.as_str()).collect();
2070        // position 0, then 2, then the unset (sorts last despite name "Aardvark").
2071        assert_eq!(order, vec!["rk-pos0", "rk-pos2", "rk-nopos"]);
2072    }
2073
2074    #[test]
2075    fn saved_sort_newest_first_by_created_at() {
2076        let mut saved: Vec<(String, Saved)> = parse_all(json!({
2077            "records": [
2078                {
2079                    "uri": "at://did:plc:x/community.lexicon.rss.saved/rk-old",
2080                    "value": { "url": "https://e.example/1", "createdAt": "2026-07-10T00:00:00.000Z" }
2081                },
2082                {
2083                    "uri": "at://did:plc:x/community.lexicon.rss.saved/rk-new",
2084                    "value": { "url": "https://e.example/2", "createdAt": "2026-07-12T00:00:00.000Z" }
2085                }
2086            ]
2087        }));
2088        saved.sort_by(|(a_key, a), (b_key, b)| {
2089            b.created_at
2090                .cmp(&a.created_at)
2091                .then_with(|| a_key.cmp(b_key))
2092        });
2093        assert_eq!(saved[0].0, "rk-new");
2094        assert_eq!(saved[1].0, "rk-old");
2095    }
2096
2097    // -- reader-facing CRUD: bulk applyWrites shape (OPML import) ------------
2098
2099    #[test]
2100    fn bulk_subscription_creates_render_sidecar_applywrites_ops() {
2101        // Mirror what `add_subscriptions_bulk` builds: one create op per feed,
2102        // each with a client-assigned rkey, in the sidecar's `applyWrites` shape.
2103        let subs = [
2104            Subscription::new("https://a.example/feed", "2026-07-12T00:00:00.000Z"),
2105            Subscription::new("https://b.example/feed", "2026-07-12T00:00:00.000Z"),
2106        ];
2107        let mut gen = TidGenerator::new();
2108        let ops: Vec<Value> = subs
2109            .iter()
2110            .map(|sub| {
2111                WriteOp::Create {
2112                    collection: lexicon::nsid::SUBSCRIPTION.to_string(),
2113                    rkey: Some(gen.next()),
2114                    value: serde_json::to_value(sub).expect("value"),
2115                }
2116                .to_sidecar_json()
2117            })
2118            .collect();
2119
2120        assert_eq!(ops.len(), 2);
2121        for op in &ops {
2122            assert_eq!(op["action"], json!("create"));
2123            assert_eq!(
2124                op["collection"],
2125                json!("community.lexicon.rss.subscription")
2126            );
2127            assert!(op["rkey"].is_string(), "bulk import pins client-side rkeys");
2128            assert_eq!(
2129                op["value"]["$type"],
2130                json!("community.lexicon.rss.subscription")
2131            );
2132        }
2133        // Distinct, ascending rkeys keep OPML order deterministic.
2134        let k0 = ops[0]["rkey"].as_str().unwrap();
2135        let k1 = ops[1]["rkey"].as_str().unwrap();
2136        assert!(k0 < k1, "bulk rkeys must sort in input order ({k0} < {k1})");
2137    }
2138
2139    // -- TID rkeys ----------------------------------------------------------
2140
2141    #[test]
2142    fn tid_rkeys_are_13_char_s32_and_monotonic() {
2143        let mut gen = TidGenerator::new();
2144        let mut prev: Option<String> = None;
2145        for _ in 0..1000 {
2146            let tid = gen.next();
2147            assert_eq!(tid.len(), 13, "a TID is 13 s32 chars");
2148            assert!(
2149                tid.bytes().all(|b| S32_ALPHABET.contains(&b)),
2150                "TID {tid} uses only the s32 alphabet"
2151            );
2152            if let Some(p) = &prev {
2153                assert!(*p < tid, "TIDs must be strictly increasing ({p} < {tid})");
2154            }
2155            prev = Some(tid);
2156        }
2157    }
2158
2159    #[test]
2160    fn tid_rkeys_are_valid_atproto_record_keys() {
2161        // atproto rkey charset: [A-Za-z0-9._~:-], length 1..=512, not "."/"..".
2162        let mut gen = TidGenerator::new();
2163        let tid = gen.next();
2164        assert!(!tid.is_empty() && tid.len() <= 512);
2165        assert!(tid != "." && tid != "..");
2166        assert!(tid
2167            .bytes()
2168            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b':' | b'-')));
2169    }
2170
2171    #[test]
2172    fn s32_encoding_is_ascending_for_ascending_values() {
2173        // The whole point of s32: numeric order == lexicographic string order.
2174        assert!(encode_s32_tid(1) < encode_s32_tid(2));
2175        assert!(encode_s32_tid(31) < encode_s32_tid(32));
2176        assert!(encode_s32_tid(1_000_000) < encode_s32_tid(1_000_001));
2177        // Ordering holds all the way to the largest real TID value (a 53-bit
2178        // microsecond timestamp shifted into bits 63..10, plus the clock id).
2179        let max_tid = (0x001f_ffff_ffff_ffffu64 << 10) | 0x3ff;
2180        assert!(encode_s32_tid(max_tid - 1) < encode_s32_tid(max_tid));
2181    }
2182}