Rust CLI for tangled

Fix ServiceAuth token expiration and spindle URL handling

ServiceAuth token fixes:
- Reduce expiration from 600 to 60 seconds for method-less tokens
per AT Protocol spec (fixes BadExpiration error)

Spindle URL handling:
- Support URLs with or without protocol prefix (e.g., "spindle.vitorpy.com")
- Add protocol (https://) automatically in xrpc_url() when missing
- Extract host correctly for ServiceAuth audience DID in both cases
- Read spindle URL from repo's spindle field for secret operations
- Fall back to TANGLED_SPINDLE_BASE env var or default

Secret operations fixes:
- Add new post() method for endpoints that return empty responses
- Update add_repo_secret() and remove_repo_secret() to use post()
instead of post_json() (fixes JSON parsing error on empty response)
- All secret operations now connect to correct spindle instance

Other improvements:
- Add spindle field to RepoRecord struct
- Display spindle URL in repo info output
- Ensure all three secret operations (list, add, remove) use the
repo's configured spindle instance

Changed files
+76 -20
crates
tangled-api
src
tangled-cli
src
commands
+47 -17
crates/tangled-api/src/client.rs
··· 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>( ··· 47 return Err(anyhow!("{}: {}", status, body)); 48 } 49 Ok(res.json::<TRes>().await?) 50 } 51 52 pub async fn get_json<TRes: DeserializeOwned>( ··· 264 struct GetSARes { 265 token: String, 266 } 267 let params = [ 268 ("aud", audience), 269 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 270 ]; 271 let sa: GetSARes = pds_client 272 .get_json( ··· 345 rkey, 346 knot, 347 description: item.value.description, 348 }); 349 } 350 } ··· 389 struct GetSARes { 390 token: String, 391 } 392 let params = [ 393 ("aud", audience), 394 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 395 ]; 396 let sa: GetSARes = pds_client 397 .get_json( ··· 1025 key, 1026 value, 1027 }; 1028 - let _: serde_json::Value = self 1029 - .post_json("sh.tangled.repo.addSecret", &body, Some(&sa)) 1030 - .await?; 1031 - Ok(()) 1032 } 1033 1034 pub async fn remove_repo_secret( ··· 1045 key: &'a str, 1046 } 1047 let body = Req { repo: repo_at, key }; 1048 - let _: serde_json::Value = self 1049 - .post_json("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1050 - .await?; 1051 - Ok(()) 1052 } 1053 1054 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1055 - let host = self 1056 - .base_url 1057 - .trim_end_matches('/') 1058 .strip_prefix("https://") 1059 - .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 1060 - .ok_or_else(|| anyhow!("invalid base_url"))?; 1061 let audience = format!("did:web:{}", host); 1062 #[derive(Deserialize)] 1063 struct GetSARes { 1064 token: String, 1065 } 1066 let pds = TangledClient::new(pds_base); 1067 let params = [ 1068 ("aud", audience), 1069 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 1070 ]; 1071 let sa: GetSARes = pds 1072 .get_json( ··· 1328 pub rkey: String, 1329 pub knot: String, 1330 pub description: Option<String>, 1331 } 1332 1333 #[derive(Debug, Clone, Serialize, Deserialize)]
··· 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 + let base = self.base_url.trim_end_matches('/'); 27 + // Add https:// if no protocol is present 28 + let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") { 29 + base.to_string() 30 + } else { 31 + format!("https://{}", base) 32 + }; 33 + format!("{}/xrpc/{}", base_with_protocol, method) 34 } 35 36 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( ··· 54 return Err(anyhow!("{}: {}", status, body)); 55 } 56 Ok(res.json::<TRes>().await?) 57 + } 58 + 59 + async fn post<TReq: Serialize>( 60 + &self, 61 + method: &str, 62 + req: &TReq, 63 + bearer: Option<&str>, 64 + ) -> Result<()> { 65 + let url = self.xrpc_url(method); 66 + let client = reqwest::Client::new(); 67 + let mut reqb = client 68 + .post(url) 69 + .header(reqwest::header::CONTENT_TYPE, "application/json"); 70 + if let Some(token) = bearer { 71 + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 72 + } 73 + let res = reqb.json(req).send().await?; 74 + let status = res.status(); 75 + if !status.is_success() { 76 + let body = res.text().await.unwrap_or_default(); 77 + return Err(anyhow!("{}: {}", status, body)); 78 + } 79 + Ok(()) 80 } 81 82 pub async fn get_json<TRes: DeserializeOwned>( ··· 294 struct GetSARes { 295 token: String, 296 } 297 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 298 let params = [ 299 ("aud", audience), 300 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 301 ]; 302 let sa: GetSARes = pds_client 303 .get_json( ··· 376 rkey, 377 knot, 378 description: item.value.description, 379 + spindle: item.value.spindle, 380 }); 381 } 382 } ··· 421 struct GetSARes { 422 token: String, 423 } 424 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 425 let params = [ 426 ("aud", audience), 427 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 428 ]; 429 let sa: GetSARes = pds_client 430 .get_json( ··· 1058 key, 1059 value, 1060 }; 1061 + self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1062 + .await 1063 } 1064 1065 pub async fn remove_repo_secret( ··· 1076 key: &'a str, 1077 } 1078 let body = Req { repo: repo_at, key }; 1079 + self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1080 + .await 1081 } 1082 1083 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1084 + let base_trimmed = self.base_url.trim_end_matches('/'); 1085 + let host = base_trimmed 1086 .strip_prefix("https://") 1087 + .or_else(|| base_trimmed.strip_prefix("http://")) 1088 + .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1089 let audience = format!("did:web:{}", host); 1090 #[derive(Deserialize)] 1091 struct GetSARes { 1092 token: String, 1093 } 1094 let pds = TangledClient::new(pds_base); 1095 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1096 let params = [ 1097 ("aud", audience), 1098 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1099 ]; 1100 let sa: GetSARes = pds 1101 .get_json( ··· 1357 pub rkey: String, 1358 pub knot: String, 1359 pub description: Option<String>, 1360 + pub spindle: Option<String>, 1361 } 1362 1363 #[derive(Debug, Clone, Serialize, Deserialize)]
+5
crates/tangled-cli/src/commands/repo.rs
··· 182 println!("NAME: {}", info.name); 183 println!("OWNER DID: {}", info.did); 184 println!("KNOT: {}", info.knot); 185 if let Some(desc) = info.description.as_deref() { 186 if !desc.is_empty() { 187 println!("DESCRIPTION: {}", desc);
··· 182 println!("NAME: {}", info.name); 183 println!("OWNER DID: {}", info.did); 184 println!("KNOT: {}", info.knot); 185 + if let Some(spindle) = info.spindle.as_deref() { 186 + if !spindle.is_empty() { 187 + println!("SPINDLE: {}", spindle); 188 + } 189 + } 190 if let Some(desc) = info.description.as_deref() { 191 if !desc.is_empty() { 192 println!("DESCRIPTION: {}", desc);
+24 -3
crates/tangled-cli/src/commands/spindle.rs
··· 220 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 221 .await?; 222 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 223 - let api = tangled_api::TangledClient::default(); // base tngl.sh 224 let secrets = api 225 .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 226 .await?; ··· 251 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 252 .await?; 253 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 254 - let api = tangled_api::TangledClient::default(); 255 api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 256 .await?; 257 println!("Added secret '{}' to {}", args.key, args.repo); ··· 274 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 275 .await?; 276 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 277 - let api = tangled_api::TangledClient::default(); 278 api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 279 .await?; 280 println!("Removed secret '{}' from {}", args.key, args.repo);
··· 220 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 221 .await?; 222 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 223 + 224 + // Get spindle base from repo config or use default 225 + let spindle_base = info.spindle 226 + .clone() 227 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 228 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 229 + let api = tangled_api::TangledClient::new(&spindle_base); 230 + 231 let secrets = api 232 .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 233 .await?; ··· 258 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 259 .await?; 260 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 261 + 262 + // Get spindle base from repo config or use default 263 + let spindle_base = info.spindle 264 + .clone() 265 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 266 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 267 + let api = tangled_api::TangledClient::new(&spindle_base); 268 + 269 api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 270 .await?; 271 println!("Added secret '{}' to {}", args.key, args.repo); ··· 288 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 289 .await?; 290 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 291 + 292 + // Get spindle base from repo config or use default 293 + let spindle_base = info.spindle 294 + .clone() 295 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 296 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 297 + let api = tangled_api::TangledClient::new(&spindle_base); 298 + 299 api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 300 .await?; 301 println!("Removed secret '{}' from {}", args.key, args.repo);