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(¶ms)
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", ¶ms, 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", ¶ms, 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 ¶ms,
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}