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
···
}
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<TReq: Serialize, TRes: DeserializeOwned>(
···
return Err(anyhow!("{}: {}", status, body));
}
Ok(res.json::<TRes>().await?)
+
}
+
+
async fn post<TReq: Serialize>(
+
&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<TRes: DeserializeOwned>(
···
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(
···
rkey,
knot,
description: item.value.description,
+
spindle: item.value.spindle,
});
}
}
···
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(
···
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(
···
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<String> {
-
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(
···
pub rkey: String,
pub knot: String,
pub description: Option<String>,
+
pub spindle: Option<String>,
#[derive(Debug, Clone, Serialize, Deserialize)]
+5
crates/tangled-cli/src/commands/repo.rs
···
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);
+24 -3
crates/tangled-cli/src/commands/spindle.rs
···
.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?;
···
.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);
···
.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);