From 24f0702bd0801a84cf8bb3a080d02c8ebda64109 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 21 Aug 2026 23:24:02 -0400 Subject: [PATCH] docs(plan): PDS spaces-capability implementation plan --- ...-08-21-pds-spaces-capability-api-design.md | 7 +- .../2026-08-21-pds-spaces-capability-plan.md | 957 ++++++++++++++++++ 2 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 docs/specs/2026-08-21-pds-spaces-capability-plan.md diff --git a/docs/specs/2026-08-21-pds-spaces-capability-api-design.md b/docs/specs/2026-08-21-pds-spaces-capability-api-design.md index bf616f1..c9f85c1 100644 --- a/docs/specs/2026-08-21-pds-spaces-capability-api-design.md +++ b/docs/specs/2026-08-21-pds-spaces-capability-api-design.md @@ -138,9 +138,10 @@ network failure. ## Config -Add optional `slingshot_base: Option` to `Config` (`crates/core/src/config.rs`), -loaded from `LGTM_SLINGSHOT_BASE`, defaulting to the existing `SLINGSHOT_DEFAULT` constant. -No new secrets. +`Resolver::from_env()` reads `LGTM_SLINGSHOT_BASE` directly (defaulting to the existing +`SLINGSHOT_DEFAULT` constant) — matching the codebase's other direct env reads +(`LGTM_STRIPE_AUTOMATIC_TAX`, `LGTM_BUILD_VERSION`) rather than threading a new `Config` field +through every `AppState`/`Config` literal. No new secrets. ## Consumers (out of scope to build, documented for contract) diff --git a/docs/specs/2026-08-21-pds-spaces-capability-plan.md b/docs/specs/2026-08-21-pds-spaces-capability-plan.md new file mode 100644 index 0000000..73ad810 --- /dev/null +++ b/docs/specs/2026-08-21-pds-spaces-capability-plan.md @@ -0,0 +1,957 @@ +# PDS spaces-capability API + `Actor` abstraction — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose whether a buyer's atproto PDS is spaces-compatible via `GET /api/pds-capabilities?actor=`, and add a reusable `Actor` (``) input type used by that endpoint and the existing DID-only XRPC read params. + +**Architecture:** New `Actor` type + `Resolver` (Slingshot-fed, moka-cached) in `crates/atproto`. A new `/api/pds-capabilities` route in `crates/server`. The spaces check is the proven heuristic from `spikes/spaces-alpha/`: GET a POST-only space procedure; `400 "Incorrect HTTP method"` ⇒ compatible, anything else ⇒ not. The `Resolver` is injected as an axum `Extension` (matching the existing `CartStore`/OAuth-client pattern), so no `AppState` construction changes. + +**Tech Stack:** Rust, axum, moka (async cache), reqwest, serde. Design spec: `docs/specs/2026-08-21-pds-spaces-capability-api-design.md`. + +**Key file anchors (current code):** +- `crates/atproto/src/identity.rs` — `resolve_did_doc` (:90), `pds_endpoint` (:24), `SLINGSHOT_DEFAULT` (:52). +- `crates/atproto/src/lib.rs` — module list (:1-10). +- `crates/atproto/src/error.rs` — `Error` enum (:1-21). +- `crates/server/src/routes/xrpc.rs` — error helpers (:48-69), `list_addresses` (:1128), `get_account` (:1165), route table `xrpc_routes` (:73), `resolve_buyer_order_space` (used at :1138/:1190), `create_address` (~:1030), `get_order` (~:607). +- `crates/server/src/app.rs` — router merges (:42-46). +- `crates/server/src/routes/mod.rs` — route module list. + +--- + +### Task 1: Add `moka` and the `Actor` type + +**Files:** +- Modify: `Cargo.toml` (workspace `[workspace.dependencies]`) +- Modify: `crates/atproto/Cargo.toml` +- Create: `crates/atproto/src/actor.rs` +- Modify: `crates/atproto/src/lib.rs:1-10` + +- [ ] **Step 1: Add moka to the workspace deps** + +Run to confirm it's not already present: +```bash +grep -n "moka" Cargo.toml || echo "not present" +``` +If absent, add under `[workspace.dependencies]` in the root `Cargo.toml`: +```toml +moka = { version = "0.12", features = ["future"] } +``` + +- [ ] **Step 2: Add moka + chrono to the atproto crate** + +In `crates/atproto/Cargo.toml`, under `[dependencies]`, after the `sqlx` line: +```toml +moka = { workspace = true } +``` + +- [ ] **Step 3: Write the failing `Actor` tests** + +Create `crates/atproto/src/actor.rs`: +```rust +use std::str::FromStr; + +use lgtm_core::ids::Did; +use serde::Deserialize; + +/// An atproto actor identifier: either a DID or a handle. Anywhere the API accepts +/// "who", it accepts both. Deserializes from a bare string via `try_from`. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(try_from = "String")] +pub enum Actor { + Did(Did), + Handle(String), +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum ActorParseError { + #[error("empty actor")] + Empty, + #[error("not a valid DID or handle: {0}")] + Invalid(String), +} + +impl FromStr for Actor { + type Err = ActorParseError; + fn from_str(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(ActorParseError::Empty); + } + if s.starts_with("did:") { + // did:: — require a non-empty method and id. + let parts: Vec<&str> = s.splitn(3, ':').collect(); + if parts.len() == 3 && !parts[1].is_empty() && !parts[2].is_empty() { + return Ok(Actor::Did(Did(s.to_string()))); + } + return Err(ActorParseError::Invalid(s.to_string())); + } + if is_valid_handle(s) { + return Ok(Actor::Handle(s.to_ascii_lowercase())); + } + Err(ActorParseError::Invalid(s.to_string())) + } +} + +impl TryFrom for Actor { + type Error = ActorParseError; + fn try_from(s: String) -> Result { + Actor::from_str(&s) + } +} + +impl std::fmt::Display for Actor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Actor::Did(d) => f.write_str(&d.0), + Actor::Handle(h) => f.write_str(h), + } + } +} + +/// Lightweight atproto handle syntax check (not DNS): 2+ dot-separated labels, each +/// 1-63 chars of `[a-z0-9-]`, not starting/ending with `-`, total <= 253, and the +/// final label (TLD) must not be all-numeric. +fn is_valid_handle(s: &str) -> bool { + if s.len() > 253 || s.contains(char::is_whitespace) { + return false; + } + let labels: Vec<&str> = s.split('.').collect(); + if labels.len() < 2 { + return false; + } + for label in &labels { + let bytes = label.as_bytes(); + if bytes.is_empty() || bytes.len() > 63 { + return false; + } + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return false; + } + if !label + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return false; + } + } + let tld = labels.last().unwrap(); + !tld.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_did() { + assert_eq!( + Actor::from_str("did:plc:abc123").unwrap(), + Actor::Did(Did("did:plc:abc123".into())) + ); + assert_eq!( + Actor::from_str("did:web:example.com").unwrap(), + Actor::Did(Did("did:web:example.com".into())) + ); + } + + #[test] + fn parses_handle_lowercased() { + assert_eq!( + Actor::from_str("Natemoo.re").unwrap(), + Actor::Handle("natemoo.re".into()) + ); + assert_eq!( + Actor::from_str("lgtm-shop.spaces-alpha.bsky.network").unwrap(), + Actor::Handle("lgtm-shop.spaces-alpha.bsky.network".into()) + ); + } + + #[test] + fn rejects_garbage() { + assert_eq!(Actor::from_str("").unwrap_err(), ActorParseError::Empty); + assert_eq!(Actor::from_str(" ").unwrap_err(), ActorParseError::Empty); + assert!(matches!(Actor::from_str("nodot").unwrap_err(), ActorParseError::Invalid(_))); + assert!(matches!(Actor::from_str("has space.com").unwrap_err(), ActorParseError::Invalid(_))); + assert!(matches!(Actor::from_str("did:only").unwrap_err(), ActorParseError::Invalid(_))); + assert!(matches!(Actor::from_str("-bad.com").unwrap_err(), ActorParseError::Invalid(_))); + assert!(matches!(Actor::from_str("127.0.0.1").unwrap_err(), ActorParseError::Invalid(_))); + } + + #[test] + fn deserializes_from_string() { + let a: Actor = serde_json::from_str("\"did:plc:xyz\"").unwrap(); + assert_eq!(a, Actor::Did(Did("did:plc:xyz".into()))); + } +} +``` + +- [ ] **Step 4: Register the module** + +In `crates/atproto/src/lib.rs`, add after `pub mod identity;` (line 2): +```rust +pub mod actor; +``` +And add to the re-exports after line 10: +```rust +pub use actor::{Actor, ActorParseError}; +``` + +- [ ] **Step 5: Run the tests** + +Run: `cargo test -p atproto actor::` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/atproto/Cargo.toml crates/atproto/src/actor.rs crates/atproto/src/lib.rs +git commit -m "feat(atproto): Actor identifier type" +``` + +--- + +### Task 2: `classify_spaces_probe` pure function + +**Files:** +- Modify: `crates/atproto/src/identity.rs` (append function + tests) + +- [ ] **Step 1: Write the failing test + function** + +Append to `crates/atproto/src/identity.rs` (before the `#[cfg(test)]` module at line 114): +```rust +/// A POST-only space procedure. GET it to probe: a spaces PDS has the route registered +/// and rejects the wrong method (HTTP 400 "Incorrect HTTP method"); a stable PDS doesn't +/// know the route and falls through to its auth guard (HTTP 401). See spikes/spaces-alpha. +pub const SPACES_PROBE_NSID: &str = "com.atproto.simplespace.createSpace"; + +/// Classify a probe response. Total over its inputs (never panics); anything that isn't a +/// 400 method-mismatch is treated as "not spaces-compatible". +pub fn classify_spaces_probe(status: u16, body: &str) -> bool { + status == 400 && body.contains("Incorrect HTTP method") +} +``` + +Append inside the existing `#[cfg(test)] mod tests` block (before its closing `}` at line 250): +```rust + #[test] + fn test_classify_spaces_probe() { + // spaces PDS: GET on a POST procedure -> 400 method mismatch + assert!(classify_spaces_probe( + 400, + r#"{"error":"InvalidRequest","message":"Incorrect HTTP method (GET) expected POST"}"# + )); + // stable PDS: unknown route -> auth guard + assert!(!classify_spaces_probe( + 401, + r#"{"error":"AuthMissing","message":"Authentication Required"}"# + )); + // other 400s (e.g. bad params) are not the method-mismatch signal + assert!(!classify_spaces_probe( + 400, + r#"{"error":"InvalidRequest","message":"Invalid space-ref"}"# + )); + assert!(!classify_spaces_probe(404, "not found")); + assert!(!classify_spaces_probe(500, "")); + } +``` + +- [ ] **Step 2: Run the test** + +Run: `cargo test -p atproto classify_spaces_probe` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/atproto/src/identity.rs +git commit -m "feat(atproto): classify_spaces_probe heuristic" +``` + +--- + +### Task 3: `Resolver` (handle→DID, DID→pds, probe) with caches + +**Files:** +- Create: `crates/atproto/src/resolver.rs` +- Modify: `crates/atproto/src/lib.rs` +- Modify: `crates/atproto/src/error.rs` (add one variant) + +- [ ] **Step 1: Add an error variant for handle resolution** + +In `crates/atproto/src/error.rs`, add inside the `Error` enum (after the `Identity` variant, line 6): +```rust + #[error("handle not found: {0}")] + HandleNotFound(String), +``` + +- [ ] **Step 2: Write the Resolver** + +Create `crates/atproto/src/resolver.rs`: +```rust +use std::time::Duration; + +use lgtm_core::ids::Did; +use moka::future::Cache; +use serde::Deserialize; + +use crate::actor::Actor; +use crate::error::{Error, Result}; +use crate::identity::{classify_spaces_probe, pds_endpoint, SLINGSHOT_DEFAULT, SPACES_PROBE_NSID}; + +/// A resolved atproto identity: the DID, the handle (if we started from one), and the PDS +/// endpoint from its DID document (None when the doc declares no `#atproto_pds`). +#[derive(Clone, Debug)] +pub struct ResolvedIdentity { + pub did: Did, + pub handle: Option, + pub pds: Option, +} + +/// Slingshot-fed identity resolver with in-memory caches, shared across all API consumers +/// via one process. Handle→DID, DID→PDS, and PDS→spaces-probe are each cached. +#[derive(Clone)] +pub struct Resolver { + http: reqwest::Client, + slingshot_base: String, + handle_to_did: Cache, + did_to_pds: Cache>, + pds_supports: Cache, +} + +impl Resolver { + pub fn new(http: reqwest::Client, slingshot_base: String) -> Self { + Self { + http, + slingshot_base, + handle_to_did: Cache::builder() + .time_to_live(Duration::from_secs(600)) + .max_capacity(10_000) + .build(), + did_to_pds: Cache::builder() + .time_to_live(Duration::from_secs(3600)) + .max_capacity(10_000) + .build(), + pds_supports: Cache::builder() + .time_to_live(Duration::from_secs(600)) + .max_capacity(10_000) + .build(), + } + } + + /// Build from env: `LGTM_SLINGSHOT_BASE` overrides the default Slingshot host. Uses a + /// pooled reqwest client with a 5s timeout (probes must fail fast, never hang a request). + pub fn from_env() -> Self { + let base = std::env::var("LGTM_SLINGSHOT_BASE") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| SLINGSHOT_DEFAULT.to_string()); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client builds"); + Self::new(http, base) + } + + /// Resolve an actor to a DID. DIDs pass through with no network; handles hit + /// Slingshot `resolveHandle` (cached). + pub async fn resolve_to_did(&self, actor: &Actor) -> Result { + match actor { + Actor::Did(did) => Ok(did.clone()), + Actor::Handle(handle) => { + if let Some(did) = self.handle_to_did.get(handle).await { + return Ok(did); + } + let did = self.fetch_handle(handle).await?; + self.handle_to_did.insert(handle.clone(), did.clone()).await; + Ok(did) + } + } + } + + /// Full resolution: DID + (handle) + PDS endpoint from the DID document. + pub async fn resolve_identity(&self, actor: &Actor) -> Result { + let did = self.resolve_to_did(actor).await?; + let handle = match actor { + Actor::Handle(h) => Some(h.clone()), + Actor::Did(_) => None, + }; + let pds = self.pds_for_did(&did).await?; + Ok(ResolvedIdentity { did, handle, pds }) + } + + /// Probe a PDS for spaces support (cached by PDS URL). Any network/parse failure is a + /// `false` for this request; the short TTL lets it recover. + pub async fn probe_spaces(&self, pds: &str) -> bool { + if let Some(ok) = self.pds_supports.get(pds).await { + return ok; + } + let ok = self.do_probe(pds).await; + self.pds_supports.insert(pds.to_string(), ok).await; + ok + } + + async fn do_probe(&self, pds: &str) -> bool { + let url = format!("{}/xrpc/{}", pds.trim_end_matches('/'), SPACES_PROBE_NSID); + let resp = match self.http.get(&url).send().await { + Ok(r) => r, + Err(_) => return false, + }; + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + classify_spaces_probe(status, &body) + } + + async fn fetch_handle(&self, handle: &str) -> Result { + let url = format!( + "{}/xrpc/com.atproto.identity.resolveHandle?handle={}", + self.slingshot_base.trim_end_matches('/'), + urlencoding_encode(handle), + ); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| Error::Http(e.to_string()))?; + if !resp.status().is_success() { + return Err(Error::HandleNotFound(handle.to_string())); + } + #[derive(Deserialize)] + struct R { + did: String, + } + let body: R = resp.json().await.map_err(|e| Error::Http(e.to_string()))?; + Ok(Did(body.did)) + } + + async fn pds_for_did(&self, did: &Did) -> Result> { + if let Some(pds) = self.did_to_pds.get(&did.0).await { + return Ok(pds); + } + let doc = crate::identity::resolve_did_doc(&self.slingshot_base, did).await?; + let pds = pds_endpoint(&doc).map(|s| s.to_string()); + self.did_to_pds.insert(did.0.clone(), pds.clone()).await; + Ok(pds) + } +} + +/// Minimal percent-encoding for a handle in a query string (handles are `[a-z0-9.-]`, so +/// this only ever needs to guard the pathological case; kept dependency-free). +fn urlencoding_encode(s: &str) -> String { + s.chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(), + _ => format!("%{:02X}", c as u32), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn did_resolves_without_network() { + // A DID actor must not require a resolver round-trip; construct with an unreachable + // base and confirm resolve_to_did still returns the DID. + let r = Resolver::new(reqwest::Client::new(), "http://127.0.0.1:1".into()); + let did = tokio_test_block(r.resolve_to_did(&Actor::Did(Did("did:plc:abc".into())))); + assert_eq!(did.unwrap(), Did("did:plc:abc".into())); + } + + fn tokio_test_block(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(f) + } + + #[tokio::test] + #[ignore = "live network: hits Slingshot + a real PDS"] + async fn live_resolve_and_probe() { + let r = Resolver::from_env(); + // natemoo.re is on a stable PDS -> not spaces-compatible + let id = r + .resolve_identity(&Actor::Handle("natemoo.re".into())) + .await + .expect("resolve"); + assert!(id.did.0.starts_with("did:")); + let pds = id.pds.expect("has pds"); + assert!(!r.probe_spaces(&pds).await, "stable PDS is not spaces-compatible"); + } +} +``` + +- [ ] **Step 3: Register the module** + +In `crates/atproto/src/lib.rs`, add after `pub mod public_repo;`: +```rust +pub mod resolver; +``` +And after the existing re-exports: +```rust +pub use resolver::{ResolvedIdentity, Resolver}; +``` + +- [ ] **Step 4: Run the tests** + +Run: `cargo test -p atproto resolver::` +Expected: PASS (1 test; the `live_` test is ignored). + +- [ ] **Step 5: Commit** + +```bash +git add crates/atproto/src/resolver.rs crates/atproto/src/lib.rs crates/atproto/src/error.rs +git commit -m "feat(atproto): Slingshot-fed Resolver with moka caches" +``` + +--- + +### Task 4: PDS-capabilities response builder (pure) + +**Files:** +- Create: `crates/server/src/routes/pds.rs` (builder + tests only in this task) + +- [ ] **Step 1: Write the failing builder + tests** + +Create `crates/server/src/routes/pds.rs`: +```rust +//! `GET /api/pds-capabilities?actor=` — reports whether an actor's PDS is +//! spaces-compatible (SPEC §8 / spikes/spaces-alpha). Consumed by the shop + `ssh lgtm.shop`. + +use std::sync::Arc; + +use axum::extract::Query; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Json, Router}; +use serde_json::json; + +use atproto::{Actor, Resolver}; + +use crate::routes::xrpc::invalid_request; +use crate::state::AppState; + +/// Build the capability JSON. Pure over its inputs so the shape + degradation are unit-tested +/// without a network. `did`/`handle`/`pds` are null when unknown; `reason` is set only when +/// `spaces_compatible` is false. +fn capability_json( + actor: &str, + did: Option<&str>, + handle: Option<&str>, + pds: Option<&str>, + spaces_compatible: bool, + reason: Option<&str>, + checked_at: &str, +) -> serde_json::Value { + json!({ + "actor": actor, + "did": did, + "handle": handle, + "pds": pds, + "spacesCompatible": spaces_compatible, + "reason": reason, + "checkedAt": checked_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatible_shape() { + let v = capability_json( + "natemoo.re", + Some("did:plc:abc"), + Some("natemoo.re"), + Some("https://pds.example"), + true, + None, + "2026-08-21T00:00:00Z", + ); + assert_eq!(v["spacesCompatible"], true); + assert_eq!(v["did"], "did:plc:abc"); + assert!(v["reason"].is_null()); + } + + #[test] + fn unresolved_degrades_gracefully() { + let v = capability_json( + "ghost.invalid", + None, + None, + None, + false, + Some("unresolved handle"), + "2026-08-21T00:00:00Z", + ); + assert_eq!(v["spacesCompatible"], false); + assert!(v["did"].is_null()); + assert_eq!(v["reason"], "unresolved handle"); + } +} +``` + +- [ ] **Step 2: Register the module (so it compiles)** + +In `crates/server/src/routes/mod.rs`, add: +```rust +pub mod pds; +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p server routes::pds::` +Expected: PASS (2 tests). (Unused-import warnings for `Query`/`get`/etc. are fine — Task 5 uses them.) + +- [ ] **Step 4: Commit** + +```bash +git add crates/server/src/routes/pds.rs crates/server/src/routes/mod.rs +git commit -m "feat(server): pds capability_json builder" +``` + +--- + +### Task 5: `/api/pds-capabilities` endpoint + wiring + +**Files:** +- Modify: `crates/server/src/routes/pds.rs` (add handler + `pds_routes`) +- Modify: `crates/server/src/app.rs:42-46` (merge route) + the two `#[cfg(test)]` routers (:97, :129) +- Modify: `crates/server/Cargo.toml` (ensure `chrono`) + +- [ ] **Step 1: Ensure `chrono` is a server dependency** + +Run: +```bash +grep -n "chrono" crates/server/Cargo.toml || echo "add chrono" +``` +If absent, add under `[dependencies]` in `crates/server/Cargo.toml`: +```toml +chrono = { workspace = true } +``` + +- [ ] **Step 2: Add the handler and router to `pds.rs`** + +Append to `crates/server/src/routes/pds.rs` (after the `capability_json` fn, before `#[cfg(test)]`): +```rust +/// A Resolver is constructed once per router build and shared via an Extension (the +/// `CartStore` pattern), so its caches are shared across every handler in this process. +pub fn pds_routes() -> Router { + let resolver = Arc::new(Resolver::from_env()); + Router::new() + .route("/api/pds-capabilities", get(pds_capabilities)) + .layer(Extension(resolver)) +} + +async fn pds_capabilities( + Extension(resolver): Extension>, + Query(params): Query>, +) -> impl IntoResponse { + let actor_str = match params.get("actor") { + Some(s) if !s.is_empty() => s.clone(), + _ => return invalid_request("missing required param: actor").into_response(), + }; + let actor: Actor = match actor_str.parse() { + Ok(a) => a, + Err(_) => return invalid_request("actor is not a valid DID or handle").into_response(), + }; + let checked_at = chrono::Utc::now().to_rfc3339(); + + match resolver.resolve_identity(&actor).await { + Err(_) => Json(capability_json( + &actor_str, None, None, None, false, Some("unresolved handle"), &checked_at, + )) + .into_response(), + Ok(id) => match id.pds.as_deref() { + None => Json(capability_json( + &actor_str, + Some(&id.did.0), + id.handle.as_deref(), + None, + false, + Some("no pds in did document"), + &checked_at, + )) + .into_response(), + Some(pds) => { + let compatible = resolver.probe_spaces(pds).await; + let reason = if compatible { + None + } else { + Some("pds does not implement com.atproto.simplespace") + }; + Json(capability_json( + &actor_str, + Some(&id.did.0), + id.handle.as_deref(), + Some(pds), + compatible, + reason, + &checked_at, + )) + .into_response() + } + }, + } +} +``` + +Then update the imports at the top of `pds.rs` — replace the `use axum::...` lines with: +```rust +use axum::extract::Query; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Json, Router}; +``` +(all now used). + +- [ ] **Step 3: Merge the route into the live router** + +In `crates/server/src/app.rs`, in `router_inner` (after line 46, `.merge(crate::routes::xrpc::xrpc_routes())`): +```rust + .merge(crate::routes::pds::pds_routes()) +``` +Add the same `.merge(crate::routes::pds::pds_routes())` line to `router_with_session_store` (after its `xrpc`/`claims` merges near line 100) and `router_with_session_store_and_dev` (near line 132) so tests that use those builders also mount the endpoint. + +- [ ] **Step 4: Write an ignored live integration test** + +Append to the `#[cfg(test)] mod tests` in `crates/server/src/routes/pds.rs`: +```rust + #[tokio::test] + #[ignore = "live network: builds a real Resolver and hits a stable PDS"] + async fn live_endpoint_reports_incompatible_for_stable_pds() { + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + let app = Router::new() + .merge(pds_routes()) + .with_state(crate::test_support::minimal_state().await); + let res = app + .oneshot( + Request::builder() + .uri("/api/pds-capabilities?actor=natemoo.re") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = axum::body::to_bytes(res.into_body(), 4096).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["spacesCompatible"], false); + assert!(v["did"].as_str().unwrap().starts_with("did:")); + } +``` + +Note: if `crate::test_support::minimal_state()` does not exist, replace the `.with_state(...)` argument with the state constructor used by other route tests (grep `fn.*AppState` under `crates/server/src`). The endpoint itself ignores `State`, so any valid `AppState` works. + +- [ ] **Step 5: Run tests + a full build** + +Run: `cargo test -p server routes::pds::` +Expected: PASS (2 unit tests; `live_` ignored). +Run: `cargo build -p server` +Expected: builds clean. + +- [ ] **Step 6: Commit** + +```bash +git add crates/server/src/routes/pds.rs crates/server/src/app.rs crates/server/Cargo.toml +git commit -m "feat(server): GET /api/pds-capabilities endpoint" +``` + +--- + +### Task 6: Retrofit XRPC read params to `Actor` + +Each handler currently pulls a raw DID string from a query param and passes it downstream. +The retrofit parses that param as an `Actor` and resolves it to a DID string via the +`Resolver`, so a **handle** works everywhere a DID did. DID inputs are unchanged (no network). + +**Files:** +- Modify: `crates/server/src/routes/xrpc.rs` — `xrpc_routes` (:73), `list_addresses` (:1128), `get_account` (:1165), `create_address` (~:1030), `get_order` (~:607) + +- [ ] **Step 1: Attach a Resolver Extension to the XRPC router** + +In `crates/server/src/routes/xrpc.rs`, in `xrpc_routes()` (line 73-75), after the `cart_store` line, add a resolver and layer it on the returned router. Change: +```rust +pub fn xrpc_routes() -> Router { + let cart_store: Arc = Arc::new(Mutex::new(HashMap::new())); + Router::new() +``` +so that the final `.layer(Extension(cart_store))` (find it near the end of the builder) is preceded by: +```rust + .layer(Extension(std::sync::Arc::new(atproto::Resolver::from_env()))) +``` +(Add the resolver layer alongside the existing cart_store layer — both Extensions coexist.) + +- [ ] **Step 2: Add a small helper to resolve an actor param to a DID string** + +Add near the error helpers in `xrpc.rs` (after `internal_error`, line 69): +```rust +/// Parse an actor query param (`?=`) and resolve it to a DID string. +/// Missing/empty -> 400; unparseable -> 400; handle that won't resolve -> 404. +async fn resolve_actor_param( + resolver: &atproto::Resolver, + params: &std::collections::HashMap, + name: &str, +) -> Result)> { + let raw = match params.get(name) { + Some(s) if !s.is_empty() => s, + _ => return Err(invalid_request(&format!("missing required param: {name}"))), + }; + let actor: atproto::Actor = raw + .parse() + .map_err(|_| invalid_request(&format!("{name} is not a valid DID or handle")))?; + resolver + .resolve_to_did(&actor) + .await + .map(|d| d.0) + .map_err(|_| not_found(&format!("{name} could not be resolved"))) +} +``` + +- [ ] **Step 3: Retrofit `list_addresses`** + +In `crates/server/src/routes/xrpc.rs`, replace the `list_addresses` signature + param block (lines 1128-1135): +```rust +async fn list_addresses( + State(state): State, + Extension(resolver): Extension>, + Query(params): Query>, +) -> impl IntoResponse { + let buyer_did_str = match resolve_actor_param(&resolver, ¶ms, "buyer").await { + Ok(d) => d, + Err(e) => return e.into_response(), + }; +``` +(The rest of the function is unchanged — it already uses `buyer_did_str`.) + +- [ ] **Step 4: Retrofit `get_account`** + +Replace the `get_account` signature + buyer block (lines 1165-1174). Add the resolver extension and swap the param extraction: +```rust +async fn get_account( + State(state): State, + Extension(cart_store): Extension>, + Extension(resolver): Extension>, + Query(params): Query>, +) -> impl IntoResponse { + // Require + resolve buyer param (accepts a handle or a DID). + let buyer_did_str = match resolve_actor_param(&resolver, ¶ms, "buyer").await { + Ok(d) => d, + Err(e) => return e.into_response(), + }; +``` +(The rest is unchanged; `buyer_did_str` is still used at :1243 and by `resolve_buyer_order_space`.) + +- [ ] **Step 5: Retrofit `create_address` (buyer is in the JSON body)** + +`create_address` (line 1066) reads `buyer` from the request **body**, not a query param, and +has no `Resolver` yet. Replace its signature + buyer block (lines 1066-1074) with: +```rust +async fn create_address( + State(state): State, + Extension(resolver): Extension>, + Json(body): Json, +) -> impl IntoResponse { + // Parse + resolve buyer (accepts a handle or a DID). + let buyer_raw = match body.get("buyer").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s, + _ => return invalid_request("missing required field: buyer").into_response(), + }; + let buyer_actor: atproto::Actor = match buyer_raw.parse() { + Ok(a) => a, + Err(_) => return invalid_request("buyer is not a valid DID or handle").into_response(), + }; + let buyer_did_str = match resolver.resolve_to_did(&buyer_actor).await { + Ok(d) => d.0, + Err(_) => return not_found("buyer could not be resolved").into_response(), + }; +``` +The rest of `create_address` is unchanged — it already uses `buyer_did_str` (space keying, +`space_uri` skey, etc.), which is now the resolved DID (spaces are DID-keyed, correct). + +- [ ] **Step 6 (optional stretch): `get_order` `shop` param** + +`get_order` (line 613) resolves `shop` through the `resolve_shop!` macro, where `shop` is the +**store's own identifier** guarded against `ctx.tenant.store_did`. A handle there is low value +(callers don't type the store's handle) and touches the shared macro. **Leave `get_order` as-is +unless trivial**: if you retrofit it, resolve `params.shop` through `atproto::Actor` + +`resolver.resolve_to_did` to a DID string *before* the `resolve_shop!` comparison, and add the +`Resolver` extension to its signature. Otherwise skip — the buyer-facing params (Steps 3-5) are +where `` input matters. + +- [ ] **Step 7: Run the XRPC tests** + +Run: `cargo test -p server routes::xrpc` +Expected: PASS. Existing tests pass a DID and must still pass (DID path does no network). If a test builds `xrpc_routes()` and a handler now 500s on a missing `Resolver` extension, confirm Step 1 added the `.layer(Extension(...Resolver...))` to `xrpc_routes()` (the router the tests build). + +- [ ] **Step 8: Add a DID-passthrough unit test (no network)** + +The valuable non-network assertion is that a DID actor still resolves with zero round-trips +through the retrofitted path. Add to the xrpc test module: +```rust + #[tokio::test] + async fn resolve_actor_param_passes_through_a_did() { + // A DID must resolve without any network; point the resolver at an unreachable base. + let resolver = atproto::Resolver::new(reqwest::Client::new(), "http://127.0.0.1:1".into()); + let mut params = std::collections::HashMap::new(); + params.insert("buyer".to_string(), "did:plc:abc123".to_string()); + let did = resolve_actor_param(&resolver, ¶ms, "buyer").await.unwrap(); + assert_eq!(did, "did:plc:abc123"); + + // Missing param -> 400. + let empty = std::collections::HashMap::new(); + assert!(resolve_actor_param(&resolver, &empty, "buyer").await.is_err()); + } +``` +Handle resolution over the network is already covered by `atproto`'s ignored `live_` tests; +manual check: `curl "$HOST/xrpc/shop.lgtm.commerce.listAddresses?buyer=natemoo.re"` → 200. + +- [ ] **Step 9: Commit** + +```bash +git add crates/server/src/routes/xrpc.rs +git commit -m "feat(server): accept on getAccount/listAddresses/createAddress/getOrder" +``` + +--- + +### Task 7: Document the consumer contract + +**Files:** +- Modify: `SPEC.md` (read-plane method list) or `REFERENCE.md` — whichever documents `/api` read endpoints (grep `getStorefront` / `/api/` to find the table). + +- [ ] **Step 1: Add the endpoint to the API docs** + +Find the read-plane/API endpoint table (grep `getStorefront` in `SPEC.md` and `REFERENCE.md`). Add a row/paragraph documenting: +``` +GET /api/pds-capabilities?actor= + -> { actor, did, handle, pds, spacesCompatible, reason?, checkedAt } + Stateless read projection. Resolves the actor (Slingshot, cached) and probes the PDS for + spaces support (com.atproto.simplespace). Degrades to spacesCompatible:false + reason on any + resolution/probe failure; 400 only for an unparseable actor. Consumed by the shop + ssh to + warn buyers whose PDS can't hold a portable receipt. +``` +Also note that the `Actor` (``) form is now accepted by `getAccount`, `listAddresses`, `createAddress`, and `getOrder`'s identifier params. + +- [ ] **Step 2: Commit** + +```bash +git add SPEC.md REFERENCE.md +git commit -m "docs: document /api/pds-capabilities and Actor param support" +``` + +--- + +## Final verification + +- [ ] Run the whole workspace test suite: `cargo test --workspace` — Expected: PASS (ignored live tests skipped). +- [ ] `cargo clippy --workspace -- -D warnings` — Expected: clean (fix any unused imports from the retrofits). +- [ ] Manually (optional, needs network): start the server and + `curl "http://localhost:8080/api/pds-capabilities?actor=natemoo.re"` → `spacesCompatible:false`; + `curl "http://localhost:8080/api/pds-capabilities?actor=natemoo-re.spaces-alpha.bsky.network"` → `spacesCompatible:true`. -- 2.51.2