From 8a69579eaf450bdaa72caae2cc4b03637b176be6 Mon Sep 17 00:00:00 2001 From: Vitor Py Date: Sat, 18 Oct 2025 16:30:25 +0200 Subject: [PATCH] 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 --- crates/tangled-api/src/client.rs | 64 ++++++++++++++++------ crates/tangled-cli/src/commands/repo.rs | 5 ++ crates/tangled-cli/src/commands/spindle.rs | 27 ++++++++- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index 524ee40..398bd70 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -23,7 +23,14 @@ impl TangledClient { } fn xrpc_url(&self, method: &str) -> String { - format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) + let base = self.base_url.trim_end_matches('/'); + // Add https:// if no protocol is present + let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") { + base.to_string() + } else { + format!("https://{}", base) + }; + format!("{}/xrpc/{}", base_with_protocol, method) } async fn post_json( @@ -49,6 +56,29 @@ impl TangledClient { Ok(res.json::().await?) } + async fn post( + &self, + method: &str, + req: &TReq, + bearer: Option<&str>, + ) -> Result<()> { + let url = self.xrpc_url(method); + let client = reqwest::Client::new(); + let mut reqb = client + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json"); + if let Some(token) = bearer { + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); + } + let res = reqb.json(req).send().await?; + let status = res.status(); + if !status.is_success() { + let body = res.text().await.unwrap_or_default(); + return Err(anyhow!("{}: {}", status, body)); + } + Ok(()) + } + pub async fn get_json( &self, method: &str, @@ -264,9 +294,10 @@ impl TangledClient { struct GetSARes { token: String, } + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec let params = [ ("aud", audience), - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), ]; let sa: GetSARes = pds_client .get_json( @@ -345,6 +376,7 @@ impl TangledClient { rkey, knot, description: item.value.description, + spindle: item.value.spindle, }); } } @@ -389,9 +421,10 @@ impl TangledClient { struct GetSARes { token: String, } + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec let params = [ ("aud", audience), - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), ]; let sa: GetSARes = pds_client .get_json( @@ -1025,10 +1058,8 @@ impl TangledClient { key, value, }; - let _: serde_json::Value = self - .post_json("sh.tangled.repo.addSecret", &body, Some(&sa)) - .await?; - Ok(()) + self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) + .await } pub async fn remove_repo_secret( @@ -1045,28 +1076,26 @@ impl TangledClient { key: &'a str, } let body = Req { repo: repo_at, key }; - let _: serde_json::Value = self - .post_json("sh.tangled.repo.removeSecret", &body, Some(&sa)) - .await?; - Ok(()) + self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) + .await } async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result { - let host = self - .base_url - .trim_end_matches('/') + let base_trimmed = self.base_url.trim_end_matches('/'); + let host = base_trimmed .strip_prefix("https://") - .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) - .ok_or_else(|| anyhow!("invalid base_url"))?; + .or_else(|| base_trimmed.strip_prefix("http://")) + .unwrap_or(base_trimmed); // If no protocol, use the URL as-is let audience = format!("did:web:{}", host); #[derive(Deserialize)] struct GetSARes { token: String, } let pds = TangledClient::new(pds_base); + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec let params = [ ("aud", audience), - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), ]; let sa: GetSARes = pds .get_json( @@ -1328,6 +1357,7 @@ pub struct RepoRecord { pub rkey: String, pub knot: String, pub description: Option, + pub spindle: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tangled-cli/src/commands/repo.rs b/crates/tangled-cli/src/commands/repo.rs index d685b9a..c0a7bdb 100644 --- a/crates/tangled-cli/src/commands/repo.rs +++ b/crates/tangled-cli/src/commands/repo.rs @@ -182,6 +182,11 @@ async fn info(args: RepoInfoArgs) -> Result<()> { println!("NAME: {}", info.name); println!("OWNER DID: {}", info.did); println!("KNOT: {}", info.knot); + if let Some(spindle) = info.spindle.as_deref() { + if !spindle.is_empty() { + println!("SPINDLE: {}", spindle); + } + } if let Some(desc) = info.description.as_deref() { if !desc.is_empty() { println!("DESCRIPTION: {}", desc); diff --git a/crates/tangled-cli/src/commands/spindle.rs b/crates/tangled-cli/src/commands/spindle.rs index 851f87a..5ca88b4 100644 --- a/crates/tangled-cli/src/commands/spindle.rs +++ b/crates/tangled-cli/src/commands/spindle.rs @@ -220,7 +220,14 @@ async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { .get_repo_info(owner, name, Some(session.access_jwt.as_str())) .await?; let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); - let api = tangled_api::TangledClient::default(); // base tngl.sh + + // Get spindle base from repo config or use default + let spindle_base = info.spindle + .clone() + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); + let api = tangled_api::TangledClient::new(&spindle_base); + let secrets = api .list_repo_secrets(&pds, &session.access_jwt, &repo_at) .await?; @@ -251,7 +258,14 @@ async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { .get_repo_info(owner, name, Some(session.access_jwt.as_str())) .await?; let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); - let api = tangled_api::TangledClient::default(); + + // Get spindle base from repo config or use default + let spindle_base = info.spindle + .clone() + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); + let api = tangled_api::TangledClient::new(&spindle_base); + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) .await?; println!("Added secret '{}' to {}", args.key, args.repo); @@ -274,7 +288,14 @@ async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { .get_repo_info(owner, name, Some(session.access_jwt.as_str())) .await?; let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); - let api = tangled_api::TangledClient::default(); + + // Get spindle base from repo config or use default + let spindle_base = info.spindle + .clone() + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); + let api = tangled_api::TangledClient::new(&spindle_base); + api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) .await?; println!("Removed secret '{}' from {}", args.key, args.repo); -- 2.51.2