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}