1use anyhow::{anyhow, Result}; 2use serde::{de::DeserializeOwned, Deserialize, Serialize}; 3use tangled_config::session::Session; 4 5#[derive(Clone, Debug)] 6pub struct TangledClient { 7 base_url: String, 8} 9 10const REPO_CREATE: &str = "sh.tangled.repo.create"; 11 12impl Default for TangledClient { 13 fn default() -> Self { 14 Self::new("https://tngl.sh") 15 } 16} 17 18impl TangledClient { 19 pub fn new(base_url: impl Into<String>) -> Self { 20 Self { 21 base_url: base_url.into(), 22 } 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 27 } 28 29 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( 30 &self, 31 method: &str, 32 req: &TReq, 33 bearer: Option<&str>, 34 ) -> Result<TRes> { 35 let url = self.xrpc_url(method); 36 let client = reqwest::Client::new(); 37 let mut reqb = client 38 .post(url) 39 .header(reqwest::header::CONTENT_TYPE, "application/json"); 40 if let Some(token) = bearer { 41 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 42 } 43 let res = reqb.json(req).send().await?; 44 let status = res.status(); 45 if !status.is_success() { 46 let body = res.text().await.unwrap_or_default(); 47 return Err(anyhow!("{}: {}", status, body)); 48 } 49 Ok(res.json::<TRes>().await?) 50 } 51 52 async fn get_json<TRes: DeserializeOwned>( 53 &self, 54 method: &str, 55 params: &[(&str, String)], 56 bearer: Option<&str>, 57 ) -> Result<TRes> { 58 let url = self.xrpc_url(method); 59 let client = reqwest::Client::new(); 60 let mut reqb = client 61 .get(&url) 62 .query(&params) 63 .header(reqwest::header::ACCEPT, "application/json"); 64 if let Some(token) = bearer { 65 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 66 } 67 let res = reqb.send().await?; 68 let status = res.status(); 69 let body = res.text().await.unwrap_or_default(); 70 if !status.is_success() { 71 return Err(anyhow!("GET {} -> {}: {}", url, status, body)); 72 } 73 serde_json::from_str::<TRes>(&body).map_err(|e| { 74 let snippet = body.chars().take(300).collect::<String>(); 75 anyhow!( 76 "error decoding response from {}: {}\nBody (first 300 chars): {}", 77 url, 78 e, 79 snippet 80 ) 81 }) 82 } 83 84 pub async fn login_with_password( 85 &self, 86 handle: &str, 87 password: &str, 88 _pds: &str, 89 ) -> Result<Session> { 90 #[derive(Serialize)] 91 struct Req<'a> { 92 #[serde(rename = "identifier")] 93 identifier: &'a str, 94 #[serde(rename = "password")] 95 password: &'a str, 96 } 97 #[derive(Deserialize)] 98 struct Res { 99 #[serde(rename = "accessJwt")] 100 access_jwt: String, 101 #[serde(rename = "refreshJwt")] 102 refresh_jwt: String, 103 did: String, 104 handle: String, 105 } 106 let body = Req { 107 identifier: handle, 108 password, 109 }; 110 let res: Res = self 111 .post_json("com.atproto.server.createSession", &body, None) 112 .await?; 113 Ok(Session { 114 access_jwt: res.access_jwt, 115 refresh_jwt: res.refresh_jwt, 116 did: res.did, 117 handle: res.handle, 118 ..Default::default() 119 }) 120 } 121 122 pub async fn list_repos( 123 &self, 124 user: Option<&str>, 125 knot: Option<&str>, 126 starred: bool, 127 bearer: Option<&str>, 128 ) -> Result<Vec<Repository>> { 129 // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords 130 // for the collection "sh.tangled.repo". This does not go through the Tangled API base. 131 // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social). 132 // Resolve handle to DID if needed 133 let did = match user { 134 Some(u) if u.starts_with("did:") => u.to_string(), 135 Some(handle) => { 136 #[derive(Deserialize)] 137 struct Res { 138 did: String, 139 } 140 let params = [("handle", handle.to_string())]; 141 let res: Res = self 142 .get_json("com.atproto.identity.resolveHandle", &params, bearer) 143 .await?; 144 res.did 145 } 146 None => { 147 return Err(anyhow!( 148 "missing user for list_repos; provide handle or DID" 149 )); 150 } 151 }; 152 153 #[derive(Deserialize)] 154 struct RecordItem { 155 value: Repository, 156 } 157 #[derive(Deserialize)] 158 struct ListRes { 159 #[serde(default)] 160 records: Vec<RecordItem>, 161 } 162 163 let params = vec![ 164 ("repo", did), 165 ("collection", "sh.tangled.repo".to_string()), 166 ("limit", "100".to_string()), 167 ]; 168 169 let res: ListRes = self 170 .get_json("com.atproto.repo.listRecords", &params, bearer) 171 .await?; 172 let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect(); 173 // Apply optional filters client-side 174 if let Some(k) = knot { 175 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); 176 } 177 if starred { 178 // TODO: implement starred filtering when API is available. For now, no-op. 179 } 180 Ok(repos) 181 } 182 183 pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> { 184 // 1) Create the sh.tangled.repo record on the user's PDS 185 #[derive(Serialize)] 186 struct Record<'a> { 187 name: &'a str, 188 knot: &'a str, 189 #[serde(skip_serializing_if = "Option::is_none")] 190 description: Option<&'a str>, 191 #[serde(rename = "createdAt")] 192 created_at: String, 193 } 194 #[derive(Serialize)] 195 struct CreateRecordReq<'a> { 196 repo: &'a str, 197 collection: &'a str, 198 validate: bool, 199 record: Record<'a>, 200 } 201 #[derive(Deserialize)] 202 struct CreateRecordRes { 203 uri: String, 204 } 205 206 let now = chrono::Utc::now().to_rfc3339(); 207 let rec = Record { 208 name: opts.name, 209 knot: opts.knot, 210 description: opts.description, 211 created_at: now, 212 }; 213 let create_req = CreateRecordReq { 214 repo: opts.did, 215 collection: "sh.tangled.repo", 216 validate: true, 217 record: rec, 218 }; 219 220 let pds_client = TangledClient::new(opts.pds_base); 221 let created: CreateRecordRes = pds_client 222 .post_json( 223 "com.atproto.repo.createRecord", 224 &create_req, 225 Some(opts.access_jwt), 226 ) 227 .await?; 228 229 // Extract rkey from at-uri: at://did/collection/rkey 230 let rkey = created 231 .uri 232 .rsplit('/') 233 .next() 234 .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?; 235 236 // 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>) 237 let host = self 238 .base_url 239 .trim_end_matches('/') 240 .strip_prefix("https://") 241 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 242 .ok_or_else(|| anyhow!("invalid base_url"))?; 243 let audience = format!("did:web:{}", host); 244 245 #[derive(Deserialize)] 246 struct GetSARes { 247 token: String, 248 } 249 let params = [ 250 ("aud", audience), 251 ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 252 ]; 253 let sa: GetSARes = pds_client 254 .get_json( 255 "com.atproto.server.getServiceAuth", 256 &params, 257 Some(opts.access_jwt), 258 ) 259 .await?; 260 261 // 3) Call sh.tangled.repo.create with the rkey 262 #[derive(Serialize)] 263 struct CreateRepoReq<'a> { 264 rkey: &'a str, 265 #[serde(skip_serializing_if = "Option::is_none")] 266 #[serde(rename = "defaultBranch")] 267 default_branch: Option<&'a str>, 268 #[serde(skip_serializing_if = "Option::is_none")] 269 source: Option<&'a str>, 270 } 271 let req = CreateRepoReq { 272 rkey, 273 default_branch: opts.default_branch, 274 source: opts.source, 275 }; 276 // No output expected on success 277 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 278 Ok(()) 279 } 280} 281 282#[derive(Debug, Clone, Serialize, Deserialize, Default)] 283pub struct Repository { 284 pub did: Option<String>, 285 pub rkey: Option<String>, 286 pub name: String, 287 pub knot: Option<String>, 288 pub description: Option<String>, 289 #[serde(default)] 290 pub private: bool, 291} 292 293#[derive(Debug, Clone)] 294pub struct CreateRepoOptions<'a> { 295 pub did: &'a str, 296 pub name: &'a str, 297 pub knot: &'a str, 298 pub description: Option<&'a str>, 299 pub default_branch: Option<&'a str>, 300 pub source: Option<&'a str>, 301 pub pds_base: &'a str, 302 pub access_jwt: &'a str, 303}