Skip to main content

max / mnw-cli

25.5 KB · 900 lines History Blame Raw
1 //! HTTP client for the MNW internal API.
2
3 use serde::{Deserialize, Serialize};
4
5 /// User info returned from the SSH key lookup endpoint.
6 #[derive(Debug, Clone, Deserialize, Serialize)]
7 pub struct UserInfo {
8 pub user_id: String,
9 pub username: String,
10 pub display_name: Option<String>,
11 pub creator_tier: Option<String>,
12 pub can_create_projects: bool,
13 pub suspended: bool,
14 }
15
16 /// A creator's project with item count and revenue.
17 #[derive(Debug, Clone, Deserialize, Serialize)]
18 pub struct Project {
19 pub id: String,
20 pub slug: String,
21 pub title: String,
22 pub project_type: String,
23 pub is_public: bool,
24 pub item_count: i64,
25 pub revenue_cents: i64,
26 }
27
28 /// An item within a project.
29 #[derive(Debug, Clone, Deserialize, Serialize)]
30 pub struct Item {
31 pub id: String,
32 pub title: String,
33 pub item_type: String,
34 pub price_cents: i32,
35 pub is_public: bool,
36 pub sort_order: i32,
37 }
38
39 /// Period comparison stats for the creator.
40 #[derive(Debug, Clone, Deserialize, Serialize)]
41 pub struct CreatorStats {
42 pub current_revenue_cents: i64,
43 pub previous_revenue_cents: i64,
44 pub current_sales: i64,
45 pub previous_sales: i64,
46 pub current_followers: i64,
47 pub previous_followers: i64,
48 pub total_projects: i64,
49 pub total_items: i64,
50 }
51
52 /// Response from the create-item internal endpoint.
53 #[derive(Debug, Deserialize)]
54 #[allow(dead_code)]
55 pub struct ItemCreated {
56 pub item_id: String,
57 pub project_id: String,
58 }
59
60 /// Response from the presign-upload internal endpoint.
61 #[derive(Debug, Deserialize)]
62 #[allow(dead_code)]
63 pub struct PresignResponse {
64 pub upload_url: String,
65 pub s3_key: String,
66 pub expires_in: u64,
67 pub cache_control: Option<String>,
68 }
69
70 /// Full item detail returned from the get/update endpoints.
71 #[derive(Debug, Clone, Deserialize, Serialize)]
72 pub struct ItemDetail {
73 pub id: String,
74 pub title: String,
75 pub description: Option<String>,
76 pub price_cents: i32,
77 pub item_type: String,
78 pub is_public: bool,
79 pub slug: String,
80 pub sort_order: i32,
81 pub sales_count: i32,
82 pub download_count: i32,
83 pub play_count: i32,
84 pub pwyw_enabled: bool,
85 pub pwyw_min_cents: Option<i32>,
86 pub has_audio: bool,
87 pub has_cover: bool,
88 pub created_at: String,
89 pub updated_at: String,
90 }
91
92 /// A version of an item.
93 #[derive(Debug, Clone, Deserialize, Serialize)]
94 pub struct Version {
95 pub id: String,
96 pub version_number: String,
97 pub changelog: Option<String>,
98 pub file_name: Option<String>,
99 pub file_size_bytes: Option<i64>,
100 pub download_count: i32,
101 pub is_current: bool,
102 pub created_at: String,
103 }
104
105 /// A blog post summary.
106 #[derive(Debug, Clone, Deserialize, Serialize)]
107 pub struct BlogPost {
108 pub id: String,
109 pub title: String,
110 pub slug: String,
111 pub is_published: bool,
112 pub created_at: String,
113 pub updated_at: String,
114 }
115
116 /// A promo code.
117 #[derive(Debug, Clone, Deserialize, Serialize)]
118 pub struct PromoCode {
119 pub id: String,
120 pub code: String,
121 pub code_purpose: String,
122 pub discount_type: Option<String>,
123 pub discount_value: Option<i32>,
124 pub item_title: Option<String>,
125 pub project_title: Option<String>,
126 pub max_uses: Option<i32>,
127 pub use_count: i32,
128 pub created_at: String,
129 }
130
131 /// A license key.
132 #[derive(Debug, Clone, Deserialize, Serialize)]
133 pub struct LicenseKey {
134 pub id: String,
135 pub key_code: String,
136 pub activation_count: i32,
137 pub max_activations: Option<i32>,
138 pub is_revoked: bool,
139 pub created_at: String,
140 }
141
142 /// Response from the storage-info internal endpoint.
143 #[derive(Debug, Clone, Deserialize, Serialize)]
144 pub struct StorageInfo {
145 pub storage_used_bytes: i64,
146 pub max_storage_bytes: i64,
147 pub allows_file_uploads: bool,
148 }
149
150 /// A revenue bucket for analytics timeseries.
151 #[derive(Debug, Clone, Deserialize, Serialize)]
152 pub struct AnalyticsBucket {
153 pub label: String,
154 pub revenue_cents: i64,
155 pub sales_count: i64,
156 }
157
158 /// Per-project revenue summary.
159 #[derive(Debug, Clone, Deserialize, Serialize)]
160 pub struct ProjectRevenue {
161 pub id: String,
162 pub title: String,
163 pub revenue_cents: i64,
164 }
165
166 /// Analytics response with timeseries, comparison, and top projects.
167 #[derive(Debug, Clone, Deserialize, Serialize)]
168 pub struct AnalyticsData {
169 pub buckets: Vec<AnalyticsBucket>,
170 pub current_revenue_cents: i64,
171 pub previous_revenue_cents: i64,
172 pub current_sales: i64,
173 pub previous_sales: i64,
174 pub current_followers: i64,
175 pub previous_followers: i64,
176 pub top_projects: Vec<ProjectRevenue>,
177 }
178
179 /// A seller transaction.
180 #[derive(Debug, Clone, Deserialize, Serialize)]
181 pub struct Transaction {
182 pub id: String,
183 pub item_title: Option<String>,
184 pub amount_cents: i32,
185 pub status: String,
186 pub created_at: String,
187 pub completed_at: Option<String>,
188 }
189
190 /// CSV export result.
191 #[derive(Debug, Clone, Deserialize, Serialize)]
192 pub struct ExportResult {
193 pub csv: String,
194 pub row_count: usize,
195 }
196
197 /// A registered SSH key.
198 #[derive(Debug, Clone, Deserialize, Serialize)]
199 pub struct SshKeyInfo {
200 pub id: String,
201 pub label: String,
202 pub fingerprint: String,
203 pub created_at: String,
204 }
205
206 /// Response from the git authorize endpoint.
207 #[derive(Debug, Deserialize)]
208 pub struct GitAuthResponse {
209 pub repo_path: String,
210 }
211
212 /// Check response status and deserialize JSON body, or bail with error details.
213 async fn json_response<T: serde::de::DeserializeOwned>(
214 resp: reqwest::Response,
215 context: &str,
216 ) -> anyhow::Result<T> {
217 if !resp.status().is_success() {
218 let status = resp.status();
219 let body = resp.text().await.unwrap_or_else(|e| {
220 tracing::warn!(error = %e, %context, "failed to read error response body");
221 String::new()
222 });
223 if body.is_empty() {
224 anyhow::bail!("{context} failed: HTTP {status}");
225 }
226 anyhow::bail!("{context} failed: HTTP {status} — {body}");
227 }
228 Ok(resp.json().await?)
229 }
230
231 /// Check response status for success, or bail with error details.
232 async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
233 if !resp.status().is_success() {
234 let status = resp.status();
235 let body = resp.text().await.unwrap_or_else(|e| {
236 tracing::warn!(error = %e, %context, "failed to read error response body");
237 String::new()
238 });
239 if body.is_empty() {
240 anyhow::bail!("{context} failed: HTTP {status}");
241 }
242 anyhow::bail!("{context} failed: HTTP {status} — {body}");
243 }
244 Ok(())
245 }
246
247 /// Client for calling MNW internal API endpoints.
248 #[derive(Clone)]
249 pub struct MnwApiClient {
250 http: reqwest::Client,
251 base_url: String,
252 service_token: String,
253 }
254
255 impl MnwApiClient {
256 pub fn new(base_url: String, service_token: String) -> Self {
257 let http = reqwest::Client::builder()
258 .timeout(std::time::Duration::from_secs(5))
259 .build()
260 .expect("failed to build HTTP client");
261
262 Self {
263 http,
264 base_url,
265 service_token,
266 }
267 }
268
269 /// Look up a user by SSH key fingerprint.
270 /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
271 pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> {
272 let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
273 let resp = self
274 .http
275 .get(&url)
276 .bearer_auth(&self.service_token)
277 .query(&[("fingerprint", fingerprint)])
278 .send()
279 .await?;
280
281 if resp.status() == reqwest::StatusCode::NOT_FOUND {
282 return Ok(None);
283 }
284
285 if !resp.status().is_success() {
286 anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
287 }
288
289 let info: UserInfo = resp.json().await?;
290 Ok(Some(info))
291 }
292
293 /// Fetch all projects for a creator with item counts and revenue.
294 pub async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> {
295 let url = format!("{}/api/internal/creator/projects", self.base_url);
296 let resp = self
297 .http
298 .get(&url)
299 .bearer_auth(&self.service_token)
300 .query(&[("user_id", user_id)])
301 .send()
302 .await?;
303
304 json_response(resp, "get_projects").await
305 }
306
307 /// Fetch items in a project.
308 pub async fn get_project_items(
309 &self,
310 project_id: &str,
311 user_id: &str,
312 ) -> anyhow::Result<Vec<Item>> {
313 let url = format!(
314 "{}/api/internal/creator/projects/{}/items",
315 self.base_url, project_id
316 );
317 let resp = self
318 .http
319 .get(&url)
320 .bearer_auth(&self.service_token)
321 .query(&[("user_id", user_id)])
322 .send()
323 .await?;
324
325 json_response(resp, "get_project_items").await
326 }
327
328 /// Fetch period comparison stats for a creator.
329 pub async fn get_stats(&self, user_id: &str, range: &str) -> anyhow::Result<CreatorStats> {
330 let url = format!("{}/api/internal/creator/stats", self.base_url);
331 let resp = self
332 .http
333 .get(&url)
334 .bearer_auth(&self.service_token)
335 .query(&[("user_id", user_id), ("range", range)])
336 .send()
337 .await?;
338
339 json_response(resp, "get_stats").await
340 }
341
342 /// Fetch storage usage and limits for a creator.
343 pub async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> {
344 let url = format!("{}/api/internal/creator/storage", self.base_url);
345 let resp = self
346 .http
347 .get(&url)
348 .bearer_auth(&self.service_token)
349 .query(&[("user_id", user_id)])
350 .send()
351 .await?;
352
353 json_response(resp, "get_storage_info").await
354 }
355
356 /// Create an item in a project.
357 pub async fn create_item(
358 &self,
359 user_id: &str,
360 project_id: &str,
361 title: &str,
362 item_type: &str,
363 price_cents: i32,
364 ) -> anyhow::Result<ItemCreated> {
365 let url = format!("{}/api/internal/creator/items", self.base_url);
366 let resp = self
367 .http
368 .post(&url)
369 .bearer_auth(&self.service_token)
370 .json(&serde_json::json!({
371 "user_id": user_id,
372 "project_id": project_id,
373 "title": title,
374 "item_type": item_type,
375 "price_cents": price_cents,
376 }))
377 .send()
378 .await?;
379
380 json_response(resp, "create_item").await
381 }
382
383 /// Get a presigned S3 upload URL.
384 pub async fn presign_upload(
385 &self,
386 user_id: &str,
387 item_id: &str,
388 file_type: &str,
389 file_name: &str,
390 content_type: &str,
391 ) -> anyhow::Result<PresignResponse> {
392 let url = format!("{}/api/internal/upload/presign", self.base_url);
393 let resp = self
394 .http
395 .post(&url)
396 .bearer_auth(&self.service_token)
397 .json(&serde_json::json!({
398 "user_id": user_id,
399 "item_id": item_id,
400 "file_type": file_type,
401 "file_name": file_name,
402 "content_type": content_type,
403 }))
404 .send()
405 .await?;
406
407 json_response(resp, "presign_upload").await
408 }
409
410 /// Confirm a completed S3 upload.
411 pub async fn confirm_upload(
412 &self,
413 user_id: &str,
414 item_id: &str,
415 file_type: &str,
416 s3_key: &str,
417 ) -> anyhow::Result<bool> {
418 let url = format!("{}/api/internal/upload/confirm", self.base_url);
419 let resp = self
420 .http
421 .post(&url)
422 .bearer_auth(&self.service_token)
423 .json(&serde_json::json!({
424 "user_id": user_id,
425 "item_id": item_id,
426 "file_type": file_type,
427 "s3_key": s3_key,
428 }))
429 .send()
430 .await?;
431
432 #[derive(Deserialize)]
433 struct Resp {
434 success: bool,
435 }
436 let r: Resp = json_response(resp, "confirm_upload").await?;
437 Ok(r.success)
438 }
439
440 /// Fetch full item detail.
441 pub async fn get_item_detail(
442 &self,
443 user_id: &str,
444 item_id: &str,
445 ) -> anyhow::Result<ItemDetail> {
446 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
447 let resp = self
448 .http
449 .get(&url)
450 .bearer_auth(&self.service_token)
451 .query(&[("user_id", user_id)])
452 .send()
453 .await?;
454
455 json_response(resp, "get_item_detail").await
456 }
457
458 /// Update item fields. Only non-None fields are changed.
459 pub async fn update_item(
460 &self,
461 user_id: &str,
462 item_id: &str,
463 title: Option<&str>,
464 description: Option<&str>,
465 price_cents: Option<i32>,
466 is_public: Option<bool>,
467 ) -> anyhow::Result<ItemDetail> {
468 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
469 let mut body = serde_json::json!({ "user_id": user_id });
470 if let Some(t) = title {
471 body["title"] = serde_json::Value::String(t.to_string());
472 }
473 if let Some(d) = description {
474 body["description"] = serde_json::Value::String(d.to_string());
475 }
476 if let Some(p) = price_cents {
477 body["price_cents"] = serde_json::json!(p);
478 }
479 if let Some(v) = is_public {
480 body["is_public"] = serde_json::json!(v);
481 }
482
483 let resp = self
484 .http
485 .put(&url)
486 .bearer_auth(&self.service_token)
487 .json(&body)
488 .send()
489 .await?;
490
491 json_response(resp, "update_item").await
492 }
493
494 /// Delete an item permanently.
495 pub async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> {
496 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
497 let resp = self
498 .http
499 .delete(&url)
500 .bearer_auth(&self.service_token)
501 .query(&[("user_id", user_id)])
502 .send()
503 .await?;
504
505 empty_response(resp, "delete_item").await
506 }
507
508 /// Publish an item (set is_public=true).
509 pub async fn publish_item(
510 &self,
511 user_id: &str,
512 item_id: &str,
513 ) -> anyhow::Result<ItemDetail> {
514 let url = format!(
515 "{}/api/internal/creator/items/{}/publish",
516 self.base_url, item_id
517 );
518 let resp = self
519 .http
520 .post(&url)
521 .bearer_auth(&self.service_token)
522 .json(&serde_json::json!({ "user_id": user_id }))
523 .send()
524 .await?;
525
526 json_response(resp, "publish_item").await
527 }
528
529 /// Unpublish an item (set is_public=false).
530 pub async fn unpublish_item(
531 &self,
532 user_id: &str,
533 item_id: &str,
534 ) -> anyhow::Result<ItemDetail> {
535 let url = format!(
536 "{}/api/internal/creator/items/{}/unpublish",
537 self.base_url, item_id
538 );
539 let resp = self
540 .http
541 .post(&url)
542 .bearer_auth(&self.service_token)
543 .json(&serde_json::json!({ "user_id": user_id }))
544 .send()
545 .await?;
546
547 json_response(resp, "unpublish_item").await
548 }
549
550 /// Fetch versions for an item.
551 pub async fn get_item_versions(
552 &self,
553 user_id: &str,
554 item_id: &str,
555 ) -> anyhow::Result<Vec<Version>> {
556 let url = format!(
557 "{}/api/internal/creator/items/{}/versions",
558 self.base_url, item_id
559 );
560 let resp = self
561 .http
562 .get(&url)
563 .bearer_auth(&self.service_token)
564 .query(&[("user_id", user_id)])
565 .send()
566 .await?;
567
568 json_response(resp, "get_item_versions").await
569 }
570
571 /// Upload a file to S3 using a presigned URL.
572 pub async fn upload_to_s3(
573 &self,
574 presigned_url: &str,
575 file_path: &std::path::Path,
576 content_type: &str,
577 cache_control: Option<&str>,
578 ) -> anyhow::Result<()> {
579 let data = tokio::fs::read(file_path).await?;
580 let mut req = self
581 .http
582 .put(presigned_url)
583 .header("content-type", content_type)
584 .body(data);
585
586 if let Some(cc) = cache_control {
587 req = req.header("cache-control", cc);
588 }
589
590 let resp = req.send().await?;
591
592 if !resp.status().is_success() {
593 anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
594 }
595
596 Ok(())
597 }
598
599 // ── Blog posts ──
600
601 /// List blog posts for a project.
602 pub async fn list_blog_posts(
603 &self,
604 user_id: &str,
605 project_id: &str,
606 ) -> anyhow::Result<Vec<BlogPost>> {
607 let url = format!(
608 "{}/api/internal/creator/projects/{}/blog",
609 self.base_url, project_id
610 );
611 let resp = self
612 .http
613 .get(&url)
614 .bearer_auth(&self.service_token)
615 .query(&[("user_id", user_id)])
616 .send()
617 .await?;
618
619 json_response(resp, "list_blog_posts").await
620 }
621
622 /// Create a blog post.
623 pub async fn create_blog_post(
624 &self,
625 user_id: &str,
626 project_id: &str,
627 title: &str,
628 body_markdown: &str,
629 publish: bool,
630 ) -> anyhow::Result<BlogPost> {
631 let url = format!("{}/api/internal/creator/blog", self.base_url);
632 let resp = self
633 .http
634 .post(&url)
635 .bearer_auth(&self.service_token)
636 .json(&serde_json::json!({
637 "user_id": user_id,
638 "project_id": project_id,
639 "title": title,
640 "body_markdown": body_markdown,
641 "publish": publish,
642 }))
643 .send()
644 .await?;
645
646 json_response(resp, "create_blog_post").await
647 }
648
649 /// Delete a blog post.
650 pub async fn delete_blog_post(&self, user_id: &str, post_id: &str) -> anyhow::Result<()> {
651 let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id);
652 let resp = self
653 .http
654 .delete(&url)
655 .bearer_auth(&self.service_token)
656 .query(&[("user_id", user_id)])
657 .send()
658 .await?;
659
660 empty_response(resp, "delete_blog_post").await
661 }
662
663 // ── Promo codes ──
664
665 /// List promo codes for a creator.
666 pub async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result<Vec<PromoCode>> {
667 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
668 let resp = self
669 .http
670 .get(&url)
671 .bearer_auth(&self.service_token)
672 .query(&[("user_id", user_id)])
673 .send()
674 .await?;
675
676 json_response(resp, "list_promo_codes").await
677 }
678
679 /// Create a promo code.
680 pub async fn create_promo_code(
681 &self,
682 user_id: &str,
683 code: &str,
684 discount_type: &str,
685 discount_value: i32,
686 max_uses: Option<i32>,
687 project_id: Option<&str>,
688 ) -> anyhow::Result<PromoCode> {
689 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
690 let mut body = serde_json::json!({
691 "user_id": user_id,
692 "code": code,
693 "code_purpose": "discount",
694 "discount_type": discount_type,
695 "discount_value": discount_value,
696 });
697 if let Some(max) = max_uses {
698 body["max_uses"] = serde_json::json!(max);
699 }
700 if let Some(pid) = project_id {
701 body["project_id"] = serde_json::json!(pid);
702 }
703
704 let resp = self
705 .http
706 .post(&url)
707 .bearer_auth(&self.service_token)
708 .json(&body)
709 .send()
710 .await?;
711
712 json_response(resp, "create_promo_code").await
713 }
714
715 /// Delete a promo code.
716 pub async fn delete_promo_code(&self, user_id: &str, code_id: &str) -> anyhow::Result<()> {
717 let url = format!(
718 "{}/api/internal/creator/promo-codes/{}",
719 self.base_url, code_id
720 );
721 let resp = self
722 .http
723 .delete(&url)
724 .bearer_auth(&self.service_token)
725 .query(&[("user_id", user_id)])
726 .send()
727 .await?;
728
729 empty_response(resp, "delete_promo_code").await
730 }
731
732 // ── License keys ──
733
734 /// List license keys for an item.
735 pub async fn list_license_keys(
736 &self,
737 user_id: &str,
738 item_id: &str,
739 ) -> anyhow::Result<Vec<LicenseKey>> {
740 let url = format!(
741 "{}/api/internal/creator/items/{}/keys",
742 self.base_url, item_id
743 );
744 let resp = self
745 .http
746 .get(&url)
747 .bearer_auth(&self.service_token)
748 .query(&[("user_id", user_id)])
749 .send()
750 .await?;
751
752 json_response(resp, "list_license_keys").await
753 }
754
755 /// Generate a new license key for an item.
756 pub async fn generate_license_key(
757 &self,
758 user_id: &str,
759 item_id: &str,
760 ) -> anyhow::Result<LicenseKey> {
761 let url = format!(
762 "{}/api/internal/creator/items/{}/keys",
763 self.base_url, item_id
764 );
765 let resp = self
766 .http
767 .post(&url)
768 .bearer_auth(&self.service_token)
769 .json(&serde_json::json!({ "user_id": user_id }))
770 .send()
771 .await?;
772
773 json_response(resp, "generate_license_key").await
774 }
775
776 /// Revoke a license key.
777 pub async fn revoke_license_key(
778 &self,
779 user_id: &str,
780 key_id: &str,
781 ) -> anyhow::Result<()> {
782 let url = format!(
783 "{}/api/internal/creator/keys/{}/revoke",
784 self.base_url, key_id
785 );
786 let resp = self
787 .http
788 .post(&url)
789 .bearer_auth(&self.service_token)
790 .json(&serde_json::json!({ "user_id": user_id }))
791 .send()
792 .await?;
793
794 empty_response(resp, "revoke_license_key").await
795 }
796
797 // ── Analytics ──
798
799 /// Get analytics data (timeseries, period comparison, top projects).
800 pub async fn get_analytics(
801 &self,
802 user_id: &str,
803 range: &str,
804 ) -> anyhow::Result<AnalyticsData> {
805 let url = format!("{}/api/internal/creator/analytics", self.base_url);
806 let resp = self
807 .http
808 .get(&url)
809 .bearer_auth(&self.service_token)
810 .query(&[("user_id", user_id), ("range", range)])
811 .send()
812 .await?;
813
814 json_response(resp, "get_analytics").await
815 }
816
817 /// Get recent seller transactions.
818 pub async fn get_transactions(&self, user_id: &str) -> anyhow::Result<Vec<Transaction>> {
819 let url = format!("{}/api/internal/creator/transactions", self.base_url);
820 let resp = self
821 .http
822 .get(&url)
823 .bearer_auth(&self.service_token)
824 .query(&[("user_id", user_id)])
825 .send()
826 .await?;
827
828 json_response(resp, "get_transactions").await
829 }
830
831 /// Export sales as CSV string.
832 pub async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result<ExportResult> {
833 let url = format!("{}/api/internal/creator/export/sales", self.base_url);
834 let resp = self
835 .http
836 .get(&url)
837 .bearer_auth(&self.service_token)
838 .query(&[("user_id", user_id)])
839 .send()
840 .await?;
841
842 json_response(resp, "export_sales_csv").await
843 }
844
845 // ── SSH keys ──
846
847 /// Authorize a git operation and get the on-disk repo path.
848 pub async fn git_authorize(
849 &self,
850 user_id: &str,
851 operation: &str,
852 owner: &str,
853 repo_name: &str,
854 ) -> anyhow::Result<GitAuthResponse> {
855 let url = format!("{}/api/internal/git/authorize", self.base_url);
856 let resp = self
857 .http
858 .post(&url)
859 .bearer_auth(&self.service_token)
860 .json(&serde_json::json!({
861 "user_id": user_id,
862 "operation": operation,
863 "owner": owner,
864 "repo_name": repo_name,
865 }))
866 .send()
867 .await?;
868
869 if !resp.status().is_success() {
870 let status = resp.status();
871 let body = resp.text().await.unwrap_or_else(|e| {
872 tracing::warn!(error = %e, "failed to read git_authorize error body");
873 String::new()
874 });
875 // Parse JSON error if available, fall back to status text
876 let msg = serde_json::from_str::<serde_json::Value>(&body)
877 .ok()
878 .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
879 .unwrap_or_else(|| format!("HTTP {status}"));
880 anyhow::bail!("{msg}");
881 }
882
883 Ok(resp.json().await?)
884 }
885
886 /// List registered SSH keys for a user.
887 pub async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> {
888 let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
889 let resp = self
890 .http
891 .get(&url)
892 .bearer_auth(&self.service_token)
893 .query(&[("user_id", user_id)])
894 .send()
895 .await?;
896
897 json_response(resp, "list_ssh_keys").await
898 }
899 }
900