Skip to main content

max / makenotwork

50.0 KB · 1618 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(crate) 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 /// Signed actor assertion the server mints at lookup; forwarded as
15 /// `X-MNW-Actor` on internal calls so the server derives identity from an
16 /// SSH-authenticated token rather than a caller-supplied `user_id`.
17 #[serde(default)]
18 pub actor_token: String,
19 }
20
21 /// A creator's project with item count and revenue.
22 #[derive(Debug, Clone, Deserialize, Serialize)]
23 #[allow(
24 clippy::struct_field_names,
25 reason = "project_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
26 )]
27 pub(crate) struct Project {
28 pub id: String,
29 pub slug: String,
30 pub title: String,
31 pub project_type: String,
32 pub is_public: bool,
33 pub item_count: i64,
34 pub revenue_cents: i64,
35 }
36
37 /// An item within a project.
38 #[derive(Debug, Clone, Deserialize, Serialize)]
39 #[allow(
40 clippy::struct_field_names,
41 reason = "item_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
42 )]
43 pub(crate) struct Item {
44 pub id: String,
45 pub title: String,
46 pub item_type: String,
47 pub price_cents: i32,
48 pub is_public: bool,
49 pub sort_order: i32,
50 }
51
52 /// Period comparison stats for the creator.
53 #[derive(Debug, Clone, Deserialize, Serialize)]
54 pub(crate) struct CreatorStats {
55 pub current_revenue_cents: i64,
56 pub previous_revenue_cents: i64,
57 pub current_sales: i64,
58 pub previous_sales: i64,
59 pub current_followers: i64,
60 pub previous_followers: i64,
61 pub total_projects: i64,
62 pub total_items: i64,
63 }
64
65 /// Response from the create-item internal endpoint.
66 #[derive(Debug, Deserialize)]
67 #[allow(dead_code)]
68 pub(crate) struct ItemCreated {
69 pub item_id: String,
70 pub project_id: String,
71 }
72
73 /// Response from the presign-upload internal endpoint.
74 #[derive(Debug, Deserialize)]
75 #[allow(dead_code)]
76 pub(crate) struct PresignResponse {
77 pub upload_url: String,
78 pub s3_key: String,
79 pub expires_in: u64,
80 pub cache_control: Option<String>,
81 }
82
83 /// Above this size an upload goes through a multipart session instead of one
84 /// presigned PUT. The single-PUT path reads the whole file into memory, which is
85 /// fine for a small file and unacceptable for a multi-GB one; the multipart path
86 /// holds one part at a time. It is also the only path that can carry a file past
87 /// S3's 5 GiB single-PUT ceiling, which is what the tier limits allow for.
88 pub(crate) const MULTIPART_THRESHOLD_BYTES: u64 = 64 * 1024 * 1024;
89
90 /// How many presigned part URLs to request at a time. Must not exceed the
91 /// server's own window cap.
92 const PART_URL_WINDOW: u32 = 100;
93
94 /// An opened multipart upload session.
95 #[derive(Debug, Clone, Deserialize)]
96 pub(crate) struct MultipartStart {
97 pub upload_id: String,
98 pub s3_key: String,
99 pub part_size: u64,
100 pub part_count: u32,
101 pub expires_in: u64,
102 }
103
104 /// One presigned part target, with the exact length the signature binds.
105 #[derive(Debug, Clone, Deserialize)]
106 pub(crate) struct MultipartPartUrl {
107 pub part_number: i32,
108 pub content_length: u64,
109 pub url: String,
110 }
111
112 #[derive(Debug, Deserialize)]
113 struct MultipartPartsResponse {
114 parts: Vec<MultipartPartUrl>,
115 }
116
117 /// Full item detail returned from the get/update endpoints.
118 #[derive(Debug, Clone, Deserialize, Serialize)]
119 pub(crate) struct ItemDetail {
120 pub id: String,
121 pub title: String,
122 pub description: Option<String>,
123 pub price_cents: i32,
124 pub item_type: String,
125 pub is_public: bool,
126 pub slug: String,
127 pub sort_order: i32,
128 pub sales_count: i32,
129 pub download_count: i32,
130 pub play_count: i32,
131 pub pwyw_enabled: bool,
132 pub pwyw_min_cents: Option<i32>,
133 pub has_audio: bool,
134 pub has_cover: bool,
135 pub created_at: String,
136 pub updated_at: String,
137 }
138
139 /// A version of an item.
140 #[derive(Debug, Clone, Deserialize, Serialize)]
141 #[allow(
142 clippy::struct_field_names,
143 reason = "version_number mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
144 )]
145 pub(crate) struct Version {
146 pub id: String,
147 pub version_number: String,
148 pub changelog: Option<String>,
149 pub file_name: Option<String>,
150 pub file_size_bytes: Option<i64>,
151 pub download_count: i32,
152 pub is_current: bool,
153 pub created_at: String,
154 }
155
156 /// A blog post summary.
157 #[derive(Debug, Clone, Deserialize, Serialize)]
158 pub(crate) struct BlogPost {
159 pub id: String,
160 pub title: String,
161 pub slug: String,
162 pub is_published: bool,
163 pub publish_at: Option<String>,
164 pub created_at: String,
165 pub updated_at: String,
166 }
167
168 /// A promo code.
169 #[derive(Debug, Clone, Deserialize, Serialize)]
170 pub(crate) struct PromoCode {
171 pub id: String,
172 pub code: String,
173 pub code_purpose: String,
174 pub discount_type: Option<String>,
175 pub discount_value: Option<i32>,
176 pub item_title: Option<String>,
177 pub project_title: Option<String>,
178 pub max_uses: Option<i32>,
179 pub use_count: i32,
180 pub created_at: String,
181 }
182
183 /// A license key.
184 #[derive(Debug, Clone, Deserialize, Serialize)]
185 pub(crate) struct LicenseKey {
186 pub id: String,
187 pub key_code: String,
188 pub activation_count: i32,
189 pub max_activations: Option<i32>,
190 pub is_revoked: bool,
191 pub created_at: String,
192 }
193
194 /// Response from the storage-info internal endpoint.
195 #[derive(Debug, Clone, Deserialize, Serialize)]
196 pub(crate) struct StorageInfo {
197 pub storage_used_bytes: i64,
198 pub max_storage_bytes: i64,
199 pub allows_file_uploads: bool,
200 }
201
202 /// A revenue bucket for analytics timeseries.
203 #[derive(Debug, Clone, Deserialize, Serialize)]
204 pub(crate) struct AnalyticsBucket {
205 pub label: String,
206 pub revenue_cents: i64,
207 pub sales_count: i64,
208 }
209
210 /// Per-project revenue summary.
211 #[derive(Debug, Clone, Deserialize, Serialize)]
212 pub(crate) struct ProjectRevenue {
213 pub id: String,
214 pub title: String,
215 pub revenue_cents: i64,
216 }
217
218 /// Analytics response with timeseries, comparison, and top projects.
219 #[derive(Debug, Clone, Deserialize, Serialize)]
220 pub(crate) struct AnalyticsData {
221 pub buckets: Vec<AnalyticsBucket>,
222 pub current_revenue_cents: i64,
223 pub previous_revenue_cents: i64,
224 pub current_sales: i64,
225 pub previous_sales: i64,
226 pub current_followers: i64,
227 pub previous_followers: i64,
228 pub top_projects: Vec<ProjectRevenue>,
229 }
230
231 /// A seller transaction.
232 #[derive(Debug, Clone, Deserialize, Serialize)]
233 pub(crate) struct Transaction {
234 pub id: String,
235 pub item_title: Option<String>,
236 pub amount_cents: i32,
237 pub status: String,
238 pub created_at: String,
239 pub completed_at: Option<String>,
240 }
241
242 /// CSV export result.
243 #[derive(Debug, Clone, Deserialize, Serialize)]
244 pub(crate) struct ExportResult {
245 pub csv: String,
246 pub row_count: usize,
247 }
248
249 /// A registered SSH key.
250 #[derive(Debug, Clone, Deserialize, Serialize)]
251 pub(crate) struct SshKeyInfo {
252 pub id: String,
253 pub label: String,
254 pub fingerprint: String,
255 pub created_at: String,
256 }
257
258 /// A tag on an item or from search.
259 #[derive(Debug, Clone, Deserialize, Serialize)]
260 pub(crate) struct TagInfo {
261 pub id: String,
262 pub name: String,
263 pub slug: String,
264 pub is_primary: bool,
265 }
266
267 /// Result of a broadcast send.
268 #[derive(Debug, Deserialize)]
269 #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not
270 pub(crate) struct BroadcastResult {
271 pub success: bool,
272 pub recipient_count: usize,
273 }
274
275 /// A subscription tier.
276 #[derive(Debug, Clone, Deserialize, Serialize)]
277 pub(crate) struct TierInfo {
278 pub id: String,
279 pub name: String,
280 pub description: String,
281 pub price_cents: i32,
282 pub is_active: bool,
283 }
284
285 /// A collection.
286 #[derive(Debug, Clone, Deserialize, Serialize)]
287 pub(crate) struct CollectionInfo {
288 pub id: String,
289 pub slug: String,
290 pub title: String,
291 pub description: String,
292 pub is_public: bool,
293 pub item_count: i64,
294 }
295
296 /// Custom domain info.
297 #[derive(Debug, Clone, Deserialize, Serialize)]
298 pub(crate) struct DomainInfo {
299 pub id: String,
300 pub domain: String,
301 pub verified: bool,
302 pub verification_token: String,
303 pub instructions: Option<String>,
304 }
305
306 /// Domain verification result.
307 #[derive(Debug, Deserialize)]
308 #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not
309 pub(crate) struct DomainVerifyResult {
310 pub verified: bool,
311 pub message: String,
312 }
313
314 /// Response from the git authorize endpoint.
315 #[derive(Debug, Deserialize)]
316 pub(crate) struct GitAuthResponse {
317 pub repo_path: String,
318 }
319
320 /// Check response status and deserialize JSON body, or bail with error details.
321 async fn json_response<T: serde::de::DeserializeOwned>(
322 resp: reqwest::Response,
323 context: &str,
324 ) -> anyhow::Result<T> {
325 if !resp.status().is_success() {
326 let status = resp.status();
327 let body = resp.text().await.unwrap_or_else(|e| {
328 tracing::warn!(error = %e, %context, "failed to read error response body");
329 String::new()
330 });
331 if body.is_empty() {
332 anyhow::bail!("{context} failed: HTTP {status}");
333 }
334 anyhow::bail!("{context} failed: HTTP {status} — {body}");
335 }
336 Ok(resp.json().await?)
337 }
338
339 /// Check response status for success, or bail with error details.
340 async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
341 if !resp.status().is_success() {
342 let status = resp.status();
343 let body = resp.text().await.unwrap_or_else(|e| {
344 tracing::warn!(error = %e, %context, "failed to read error response body");
345 String::new()
346 });
347 if body.is_empty() {
348 anyhow::bail!("{context} failed: HTTP {status}");
349 }
350 anyhow::bail!("{context} failed: HTTP {status} — {body}");
351 }
352 Ok(())
353 }
354
355 /// Client for calling MNW internal API endpoints.
356 #[derive(Clone)]
357 pub(crate) struct MnwApiClient {
358 http: reqwest::Client,
359 base_url: String,
360 service_token: String,
361 /// Set once per session from the SSH-key-lookup response; forwarded on
362 /// internal creator calls as `X-MNW-Actor`.
363 actor_token: Option<String>,
364 }
365
366 impl MnwApiClient {
367 pub(crate) fn new(base_url: String, service_token: String) -> Self {
368 let http = reqwest::Client::builder()
369 .timeout(std::time::Duration::from_secs(5))
370 .build()
371 .expect("failed to build HTTP client");
372
373 Self {
374 http,
375 base_url,
376 service_token,
377 actor_token: None,
378 }
379 }
380
381 /// Record the actor assertion for the authenticated session. Subsequent
382 /// internal calls forward it so the server can verify the acting identity.
383 pub(crate) fn set_actor_token(&mut self, token: String) {
384 self.actor_token = Some(token);
385 }
386
387 /// The `X-MNW-Actor` header value for internal calls (empty before lookup).
388 fn actor_header(&self) -> &str {
389 self.actor_token.as_deref().unwrap_or("")
390 }
391
392 /// Look up a user by SSH key fingerprint.
393 /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
394 pub(crate) async fn lookup_ssh_key(
395 &self,
396 fingerprint: &str,
397 ) -> anyhow::Result<Option<UserInfo>> {
398 let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
399 let resp = self
400 .http
401 .get(&url)
402 .bearer_auth(&self.service_token)
403 .header("X-MNW-Actor", self.actor_header())
404 .query(&[("fingerprint", fingerprint)])
405 .send()
406 .await?;
407
408 if resp.status() == reqwest::StatusCode::NOT_FOUND {
409 return Ok(None);
410 }
411
412 if !resp.status().is_success() {
413 anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
414 }
415
416 let info: UserInfo = resp.json().await?;
417 Ok(Some(info))
418 }
419
420 /// Fetch all projects for a creator with item counts and revenue.
421 pub(crate) async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> {
422 let url = format!("{}/api/internal/creator/projects", self.base_url);
423 let resp = self
424 .http
425 .get(&url)
426 .bearer_auth(&self.service_token)
427 .header("X-MNW-Actor", self.actor_header())
428 .query(&[("user_id", user_id)])
429 .send()
430 .await?;
431
432 json_response(resp, "get_projects").await
433 }
434
435 /// Create a new project.
436 pub(crate) async fn create_project(
437 &self,
438 user_id: &str,
439 title: &str,
440 project_type: &str,
441 description: Option<&str>,
442 ) -> anyhow::Result<Project> {
443 let url = format!("{}/api/internal/creator/projects", self.base_url);
444 let mut body = serde_json::json!({
445 "user_id": user_id,
446 "title": title,
447 "project_type": project_type,
448 });
449 if let Some(desc) = description {
450 body["description"] = serde_json::Value::String(desc.to_string());
451 }
452 let resp = self
453 .http
454 .post(&url)
455 .bearer_auth(&self.service_token)
456 .header("X-MNW-Actor", self.actor_header())
457 .json(&body)
458 .send()
459 .await?;
460
461 json_response(resp, "create_project").await
462 }
463
464 /// Fetch items in a project.
465 pub(crate) async fn get_project_items(
466 &self,
467 project_id: &str,
468 user_id: &str,
469 ) -> anyhow::Result<Vec<Item>> {
470 let url = format!(
471 "{}/api/internal/creator/projects/{}/items",
472 self.base_url, project_id
473 );
474 let resp = self
475 .http
476 .get(&url)
477 .bearer_auth(&self.service_token)
478 .header("X-MNW-Actor", self.actor_header())
479 .query(&[("user_id", user_id)])
480 .send()
481 .await?;
482
483 json_response(resp, "get_project_items").await
484 }
485
486 /// Fetch period comparison stats for a creator.
487 pub(crate) async fn get_stats(
488 &self,
489 user_id: &str,
490 range: &str,
491 ) -> anyhow::Result<CreatorStats> {
492 let url = format!("{}/api/internal/creator/stats", self.base_url);
493 let resp = self
494 .http
495 .get(&url)
496 .bearer_auth(&self.service_token)
497 .header("X-MNW-Actor", self.actor_header())
498 .query(&[("user_id", user_id), ("range", range)])
499 .send()
500 .await?;
501
502 json_response(resp, "get_stats").await
503 }
504
505 /// Fetch storage usage and limits for a creator.
506 pub(crate) async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> {
507 let url = format!("{}/api/internal/creator/storage", self.base_url);
508 let resp = self
509 .http
510 .get(&url)
511 .bearer_auth(&self.service_token)
512 .header("X-MNW-Actor", self.actor_header())
513 .query(&[("user_id", user_id)])
514 .send()
515 .await?;
516
517 json_response(resp, "get_storage_info").await
518 }
519
520 /// Create an item in a project.
521 pub(crate) async fn create_item(
522 &self,
523 user_id: &str,
524 project_id: &str,
525 title: &str,
526 item_type: &str,
527 price_cents: i32,
528 ) -> anyhow::Result<ItemCreated> {
529 let url = format!("{}/api/internal/creator/items", self.base_url);
530 let resp = self
531 .http
532 .post(&url)
533 .bearer_auth(&self.service_token)
534 .header("X-MNW-Actor", self.actor_header())
535 .json(&serde_json::json!({
536 "user_id": user_id,
537 "project_id": project_id,
538 "title": title,
539 "item_type": item_type,
540 "price_cents": price_cents,
541 }))
542 .send()
543 .await?;
544
545 json_response(resp, "create_item").await
546 }
547
548 /// Get a presigned S3 upload URL.
549 pub(crate) async fn presign_upload(
550 &self,
551 user_id: &str,
552 item_id: &str,
553 file_type: &str,
554 file_name: &str,
555 content_type: &str,
556 ) -> anyhow::Result<PresignResponse> {
557 let url = format!("{}/api/internal/upload/presign", self.base_url);
558 let resp = self
559 .http
560 .post(&url)
561 .bearer_auth(&self.service_token)
562 .header("X-MNW-Actor", self.actor_header())
563 .json(&serde_json::json!({
564 "user_id": user_id,
565 "item_id": item_id,
566 "file_type": file_type,
567 "file_name": file_name,
568 "content_type": content_type,
569 }))
570 .send()
571 .await?;
572
573 json_response(resp, "presign_upload").await
574 }
575
576 /// Confirm a completed S3 upload.
577 pub(crate) async fn confirm_upload(
578 &self,
579 user_id: &str,
580 item_id: &str,
581 file_type: &str,
582 s3_key: &str,
583 ) -> anyhow::Result<bool> {
584 let url = format!("{}/api/internal/upload/confirm", self.base_url);
585 let resp = self
586 .http
587 .post(&url)
588 .bearer_auth(&self.service_token)
589 .header("X-MNW-Actor", self.actor_header())
590 .json(&serde_json::json!({
591 "user_id": user_id,
592 "item_id": item_id,
593 "file_type": file_type,
594 "s3_key": s3_key,
595 }))
596 .send()
597 .await?;
598
599 #[derive(Deserialize)]
600 struct Resp {
601 success: bool,
602 }
603 let r: Resp = json_response(resp, "confirm_upload").await?;
604 Ok(r.success)
605 }
606
607 /// Fetch full item detail.
608 pub(crate) async fn get_item_detail(
609 &self,
610 user_id: &str,
611 item_id: &str,
612 ) -> anyhow::Result<ItemDetail> {
613 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
614 let resp = self
615 .http
616 .get(&url)
617 .bearer_auth(&self.service_token)
618 .header("X-MNW-Actor", self.actor_header())
619 .query(&[("user_id", user_id)])
620 .send()
621 .await?;
622
623 json_response(resp, "get_item_detail").await
624 }
625
626 /// Update item fields. Only non-None fields are changed.
627 pub(crate) async fn update_item(
628 &self,
629 user_id: &str,
630 item_id: &str,
631 title: Option<&str>,
632 description: Option<&str>,
633 price_cents: Option<i32>,
634 is_public: Option<bool>,
635 ) -> anyhow::Result<ItemDetail> {
636 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
637 let mut body = serde_json::json!({ "user_id": user_id });
638 if let Some(t) = title {
639 body["title"] = serde_json::Value::String(t.to_string());
640 }
641 if let Some(d) = description {
642 body["description"] = serde_json::Value::String(d.to_string());
643 }
644 if let Some(p) = price_cents {
645 body["price_cents"] = serde_json::json!(p);
646 }
647 if let Some(v) = is_public {
648 body["is_public"] = serde_json::json!(v);
649 }
650
651 let resp = self
652 .http
653 .put(&url)
654 .bearer_auth(&self.service_token)
655 .header("X-MNW-Actor", self.actor_header())
656 .json(&body)
657 .send()
658 .await?;
659
660 json_response(resp, "update_item").await
661 }
662
663 /// Delete an item permanently.
664 pub(crate) async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> {
665 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
666 let resp = self
667 .http
668 .delete(&url)
669 .bearer_auth(&self.service_token)
670 .header("X-MNW-Actor", self.actor_header())
671 .query(&[("user_id", user_id)])
672 .send()
673 .await?;
674
675 empty_response(resp, "delete_item").await
676 }
677
678 /// Publish an item (set is_public=true).
679 pub(crate) async fn publish_item(
680 &self,
681 user_id: &str,
682 item_id: &str,
683 ) -> anyhow::Result<ItemDetail> {
684 let url = format!(
685 "{}/api/internal/creator/items/{}/publish",
686 self.base_url, item_id
687 );
688 let resp = self
689 .http
690 .post(&url)
691 .bearer_auth(&self.service_token)
692 .header("X-MNW-Actor", self.actor_header())
693 .json(&serde_json::json!({ "user_id": user_id }))
694 .send()
695 .await?;
696
697 json_response(resp, "publish_item").await
698 }
699
700 /// Unpublish an item (set is_public=false).
701 pub(crate) async fn unpublish_item(
702 &self,
703 user_id: &str,
704 item_id: &str,
705 ) -> anyhow::Result<ItemDetail> {
706 let url = format!(
707 "{}/api/internal/creator/items/{}/unpublish",
708 self.base_url, item_id
709 );
710 let resp = self
711 .http
712 .post(&url)
713 .bearer_auth(&self.service_token)
714 .header("X-MNW-Actor", self.actor_header())
715 .json(&serde_json::json!({ "user_id": user_id }))
716 .send()
717 .await?;
718
719 json_response(resp, "unpublish_item").await
720 }
721
722 /// Fetch versions for an item.
723 pub(crate) async fn get_item_versions(
724 &self,
725 user_id: &str,
726 item_id: &str,
727 ) -> anyhow::Result<Vec<Version>> {
728 let url = format!(
729 "{}/api/internal/creator/items/{}/versions",
730 self.base_url, item_id
731 );
732 let resp = self
733 .http
734 .get(&url)
735 .bearer_auth(&self.service_token)
736 .header("X-MNW-Actor", self.actor_header())
737 .query(&[("user_id", user_id)])
738 .send()
739 .await?;
740
741 json_response(resp, "get_item_versions").await
742 }
743
744 // ── Multipart upload session (large files) ──
745
746 /// Open a multipart upload session and get the part geometry.
747 pub(crate) async fn multipart_start(
748 &self,
749 item_id: &str,
750 file_type: &str,
751 file_name: &str,
752 content_type: &str,
753 file_size_bytes: u64,
754 ) -> anyhow::Result<MultipartStart> {
755 let url = format!("{}/api/internal/upload/multipart/start", self.base_url);
756 let resp = self
757 .http
758 .post(&url)
759 .bearer_auth(&self.service_token)
760 .header("X-MNW-Actor", self.actor_header())
761 .json(&serde_json::json!({
762 "item_id": item_id,
763 "file_type": file_type,
764 "file_name": file_name,
765 "content_type": content_type,
766 "file_size_bytes": file_size_bytes,
767 }))
768 .send()
769 .await?;
770
771 json_response(resp, "multipart_start").await
772 }
773
774 /// Fetch a bounded window of presigned part URLs.
775 async fn multipart_parts(
776 &self,
777 s3_key: &str,
778 upload_id: &str,
779 file_size_bytes: u64,
780 first_part: u32,
781 count: u32,
782 ) -> anyhow::Result<Vec<MultipartPartUrl>> {
783 let url = format!("{}/api/internal/upload/multipart/parts", self.base_url);
784 let resp = self
785 .http
786 .post(&url)
787 .bearer_auth(&self.service_token)
788 .header("X-MNW-Actor", self.actor_header())
789 .json(&serde_json::json!({
790 "s3_key": s3_key,
791 "upload_id": upload_id,
792 "file_size_bytes": file_size_bytes,
793 "first_part": first_part,
794 "count": count,
795 }))
796 .send()
797 .await?;
798
799 let parts: MultipartPartsResponse = json_response(resp, "multipart_parts").await?;
800 Ok(parts.parts)
801 }
802
803 /// Assemble the uploaded parts into the staging object.
804 async fn multipart_complete(
805 &self,
806 s3_key: &str,
807 upload_id: &str,
808 parts: &[(i32, String)],
809 ) -> anyhow::Result<()> {
810 let url = format!("{}/api/internal/upload/multipart/complete", self.base_url);
811 let parts: Vec<serde_json::Value> = parts
812 .iter()
813 .map(|(n, etag)| serde_json::json!({ "part_number": n, "etag": etag }))
814 .collect();
815 let resp = self
816 .http
817 .post(&url)
818 .bearer_auth(&self.service_token)
819 .header("X-MNW-Actor", self.actor_header())
820 .json(&serde_json::json!({
821 "s3_key": s3_key,
822 "upload_id": upload_id,
823 "parts": parts,
824 }))
825 .send()
826 .await?;
827
828 if !resp.status().is_success() {
829 anyhow::bail!(
830 "multipart_complete failed: HTTP {} {}",
831 resp.status(),
832 resp.text().await.unwrap_or_default()
833 );
834 }
835 Ok(())
836 }
837
838 /// Release the parts of an abandoned session. Incomplete multipart uploads
839 /// bill for their parts until aborted.
840 pub(crate) async fn multipart_abort(
841 &self,
842 s3_key: &str,
843 upload_id: &str,
844 ) -> anyhow::Result<()> {
845 let url = format!("{}/api/internal/upload/multipart/abort", self.base_url);
846 let resp = self
847 .http
848 .post(&url)
849 .bearer_auth(&self.service_token)
850 .header("X-MNW-Actor", self.actor_header())
851 .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id }))
852 .send()
853 .await?;
854
855 if !resp.status().is_success() {
856 anyhow::bail!("multipart_abort failed: HTTP {}", resp.status());
857 }
858 Ok(())
859 }
860
861 /// Upload a file through a multipart session, holding one part in memory at
862 /// a time. Returns the staging key to confirm against.
863 ///
864 /// `on_progress` is called with `(bytes_uploaded, total)` after each part.
865 /// Any failure past the session opening aborts it, so a half-finished upload
866 /// does not leave parts billing indefinitely — the single abort site means a
867 /// future failure path added inside cannot forget to.
868 #[allow(clippy::too_many_arguments)]
869 pub(crate) async fn upload_file_multipart(
870 &self,
871 item_id: &str,
872 file_type: &str,
873 file_name: &str,
874 content_type: &str,
875 file_path: &std::path::Path,
876 file_size: u64,
877 mut on_progress: impl FnMut(u64, u64),
878 ) -> anyhow::Result<String> {
879 let start = self
880 .multipart_start(item_id, file_type, file_name, content_type, file_size)
881 .await?;
882
883 tracing::info!(
884 s3_key = %start.s3_key,
885 part_count = start.part_count,
886 part_size = start.part_size,
887 expires_in = start.expires_in,
888 file_size,
889 "multipart upload session opened"
890 );
891
892 match self
893 .run_multipart_upload(&start, file_path, file_size, &mut on_progress)
894 .await
895 {
896 Ok(()) => Ok(start.s3_key),
897 Err(e) => {
898 if let Err(abort_err) = self.multipart_abort(&start.s3_key, &start.upload_id).await
899 {
900 tracing::warn!(
901 error = %abort_err, s3_key = %start.s3_key,
902 "failed to abort multipart upload after a failed transfer"
903 );
904 }
905 Err(e)
906 }
907 }
908 }
909
910 /// Read and upload every part, then complete. Returns `Err` without
911 /// aborting; the caller owns the single abort.
912 async fn run_multipart_upload(
913 &self,
914 start: &MultipartStart,
915 file_path: &std::path::Path,
916 file_size: u64,
917 on_progress: &mut impl FnMut(u64, u64),
918 ) -> anyhow::Result<()> {
919 use tokio::io::AsyncReadExt;
920
921 let mut file = tokio::fs::File::open(file_path)
922 .await
923 .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?;
924
925 let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize);
926 let mut uploaded: u64 = 0;
927 let mut next: u32 = 1;
928
929 while next <= start.part_count {
930 let count = PART_URL_WINDOW.min(start.part_count - next + 1);
931 let urls = self
932 .multipart_parts(&start.s3_key, &start.upload_id, file_size, next, count)
933 .await?;
934 if urls.is_empty() {
935 anyhow::bail!("server returned no part URLs for part {next}");
936 }
937
938 for part in urls {
939 // One part resident at a time — this is the whole point of the
940 // multipart path over the single-PUT one.
941 let mut buf = vec![0u8; part.content_length as usize];
942 file.read_exact(&mut buf).await.map_err(|e| {
943 anyhow::anyhow!(
944 "reading part {} ({} bytes) from {}: {e}",
945 part.part_number,
946 part.content_length,
947 file_path.display()
948 )
949 })?;
950
951 let etag = self.put_part(&part, buf).await?;
952 completed.push((part.part_number, etag));
953 uploaded += part.content_length;
954 on_progress(uploaded, file_size);
955 next += 1;
956 }
957 }
958
959 self.multipart_complete(&start.s3_key, &start.upload_id, &completed)
960 .await
961 }
962
963 /// PUT one part to its presigned URL, returning the ETag the completion call
964 /// needs. Retries transient failures — losing a part to a network blip
965 /// should not discard the whole transfer.
966 async fn put_part(&self, part: &MultipartPartUrl, body: Vec<u8>) -> anyhow::Result<String> {
967 // `Bytes` so a retry clones a refcount rather than re-copying the part.
968 let body = bytes::Bytes::from(body);
969 let mut attempt: u32 = 0;
970 loop {
971 attempt += 1;
972 let sent = self
973 .http
974 .put(&part.url)
975 .header(reqwest::header::CONTENT_LENGTH, part.content_length)
976 .body(body.clone())
977 .send()
978 .await;
979
980 let retriable = match sent {
981 Ok(resp) if resp.status().is_success() => {
982 let etag = resp
983 .headers()
984 .get(reqwest::header::ETAG)
985 .and_then(|v| v.to_str().ok())
986 .unwrap_or_default()
987 .to_string();
988 if etag.is_empty() {
989 anyhow::bail!(
990 "S3 returned no ETag for part {}; cannot complete the upload",
991 part.part_number
992 );
993 }
994 return Ok(etag);
995 }
996 Ok(resp) => {
997 let status = resp.status();
998 if attempt >= 3 {
999 anyhow::bail!(
1000 "part {} failed after {attempt} attempts: HTTP {status}",
1001 part.part_number
1002 );
1003 }
1004 format!("HTTP {status}")
1005 }
1006 Err(e) => {
1007 if attempt >= 3 {
1008 return Err(anyhow::Error::new(e).context(format!(
1009 "part {} failed after {attempt} attempts",
1010 part.part_number
1011 )));
1012 }
1013 e.to_string()
1014 }
1015 };
1016
1017 let delay_ms = 200u64 * (1u64 << ((attempt - 1) * 2));
1018 tracing::warn!(
1019 part_number = part.part_number, attempt, delay_ms, error = %retriable,
1020 "part upload transient failure, retrying"
1021 );
1022 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
1023 }
1024 }
1025
1026 /// Upload a file to S3 using a presigned URL.
1027 pub(crate) async fn upload_to_s3(
1028 &self,
1029 presigned_url: &str,
1030 file_path: &std::path::Path,
1031 content_type: &str,
1032 cache_control: Option<&str>,
1033 ) -> anyhow::Result<()> {
1034 let data = tokio::fs::read(file_path).await?;
1035 let mut req = self
1036 .http
1037 .put(presigned_url)
1038 .header("content-type", content_type)
1039 .body(data);
1040
1041 if let Some(cc) = cache_control {
1042 req = req.header("cache-control", cc);
1043 }
1044
1045 let resp = req.send().await?;
1046
1047 if !resp.status().is_success() {
1048 anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
1049 }
1050
1051 Ok(())
1052 }
1053
1054 // ── Blog posts ──
1055
1056 /// List blog posts for a project.
1057 pub(crate) async fn list_blog_posts(
1058 &self,
1059 user_id: &str,
1060 project_id: &str,
1061 ) -> anyhow::Result<Vec<BlogPost>> {
1062 let url = format!(
1063 "{}/api/internal/creator/projects/{}/blog",
1064 self.base_url, project_id
1065 );
1066 let resp = self
1067 .http
1068 .get(&url)
1069 .bearer_auth(&self.service_token)
1070 .header("X-MNW-Actor", self.actor_header())
1071 .query(&[("user_id", user_id)])
1072 .send()
1073 .await?;
1074
1075 json_response(resp, "list_blog_posts").await
1076 }
1077
1078 /// Create a blog post, optionally scheduled for future publication.
1079 pub(crate) async fn create_blog_post(
1080 &self,
1081 user_id: &str,
1082 project_id: &str,
1083 title: &str,
1084 body_markdown: &str,
1085 publish: bool,
1086 publish_at: Option<&str>,
1087 ) -> anyhow::Result<BlogPost> {
1088 let url = format!("{}/api/internal/creator/blog", self.base_url);
1089 let mut body = serde_json::json!({
1090 "user_id": user_id,
1091 "project_id": project_id,
1092 "title": title,
1093 "body_markdown": body_markdown,
1094 "publish": publish,
1095 });
1096 if let Some(pa) = publish_at {
1097 body["publish_at"] = serde_json::Value::String(pa.to_string());
1098 }
1099 let resp = self
1100 .http
1101 .post(&url)
1102 .bearer_auth(&self.service_token)
1103 .header("X-MNW-Actor", self.actor_header())
1104 .json(&body)
1105 .send()
1106 .await?;
1107
1108 json_response(resp, "create_blog_post").await
1109 }
1110
1111 /// Delete a blog post.
1112 pub(crate) async fn delete_blog_post(
1113 &self,
1114 user_id: &str,
1115 post_id: &str,
1116 ) -> anyhow::Result<()> {
1117 let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id);
1118 let resp = self
1119 .http
1120 .delete(&url)
1121 .bearer_auth(&self.service_token)
1122 .header("X-MNW-Actor", self.actor_header())
1123 .query(&[("user_id", user_id)])
1124 .send()
1125 .await?;
1126
1127 empty_response(resp, "delete_blog_post").await
1128 }
1129
1130 // ── Promo codes ──
1131
1132 /// List promo codes for a creator.
1133 pub(crate) async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result<Vec<PromoCode>> {
1134 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
1135 let resp = self
1136 .http
1137 .get(&url)
1138 .bearer_auth(&self.service_token)
1139 .header("X-MNW-Actor", self.actor_header())
1140 .query(&[("user_id", user_id)])
1141 .send()
1142 .await?;
1143
1144 json_response(resp, "list_promo_codes").await
1145 }
1146
1147 /// Create a promo code.
1148 pub(crate) async fn create_promo_code(
1149 &self,
1150 user_id: &str,
1151 code: &str,
1152 discount_type: &str,
1153 discount_value: i32,
1154 max_uses: Option<i32>,
1155 project_id: Option<&str>,
1156 ) -> anyhow::Result<PromoCode> {
1157 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
1158 let mut body = serde_json::json!({
1159 "user_id": user_id,
1160 "code": code,
1161 "code_purpose": "discount",
1162 "discount_type": discount_type,
1163 "discount_value": discount_value,
1164 });
1165 if let Some(max) = max_uses {
1166 body["max_uses"] = serde_json::json!(max);
1167 }
1168 if let Some(pid) = project_id {
1169 body["project_id"] = serde_json::json!(pid);
1170 }
1171
1172 let resp = self
1173 .http
1174 .post(&url)
1175 .bearer_auth(&self.service_token)
1176 .header("X-MNW-Actor", self.actor_header())
1177 .json(&body)
1178 .send()
1179 .await?;
1180
1181 json_response(resp, "create_promo_code").await
1182 }
1183
1184 /// Delete a promo code.
1185 pub(crate) async fn delete_promo_code(
1186 &self,
1187 user_id: &str,
1188 code_id: &str,
1189 ) -> anyhow::Result<()> {
1190 let url = format!(
1191 "{}/api/internal/creator/promo-codes/{}",
1192 self.base_url, code_id
1193 );
1194 let resp = self
1195 .http
1196 .delete(&url)
1197 .bearer_auth(&self.service_token)
1198 .header("X-MNW-Actor", self.actor_header())
1199 .query(&[("user_id", user_id)])
1200 .send()
1201 .await?;
1202
1203 empty_response(resp, "delete_promo_code").await
1204 }
1205
1206 // ── License keys ──
1207
1208 /// List license keys for an item.
1209 pub(crate) async fn list_license_keys(
1210 &self,
1211 user_id: &str,
1212 item_id: &str,
1213 ) -> anyhow::Result<Vec<LicenseKey>> {
1214 let url = format!(
1215 "{}/api/internal/creator/items/{}/keys",
1216 self.base_url, item_id
1217 );
1218 let resp = self
1219 .http
1220 .get(&url)
1221 .bearer_auth(&self.service_token)
1222 .header("X-MNW-Actor", self.actor_header())
1223 .query(&[("user_id", user_id)])
1224 .send()
1225 .await?;
1226
1227 json_response(resp, "list_license_keys").await
1228 }
1229
1230 /// Generate a new license key for an item.
1231 pub(crate) async fn generate_license_key(
1232 &self,
1233 user_id: &str,
1234 item_id: &str,
1235 ) -> anyhow::Result<LicenseKey> {
1236 let url = format!(
1237 "{}/api/internal/creator/items/{}/keys",
1238 self.base_url, item_id
1239 );
1240 let resp = self
1241 .http
1242 .post(&url)
1243 .bearer_auth(&self.service_token)
1244 .header("X-MNW-Actor", self.actor_header())
1245 .json(&serde_json::json!({ "user_id": user_id }))
1246 .send()
1247 .await?;
1248
1249 json_response(resp, "generate_license_key").await
1250 }
1251
1252 /// Revoke a license key.
1253 pub(crate) async fn revoke_license_key(
1254 &self,
1255 user_id: &str,
1256 key_id: &str,
1257 ) -> anyhow::Result<()> {
1258 let url = format!(
1259 "{}/api/internal/creator/keys/{}/revoke",
1260 self.base_url, key_id
1261 );
1262 let resp = self
1263 .http
1264 .post(&url)
1265 .bearer_auth(&self.service_token)
1266 .header("X-MNW-Actor", self.actor_header())
1267 .json(&serde_json::json!({ "user_id": user_id }))
1268 .send()
1269 .await?;
1270
1271 empty_response(resp, "revoke_license_key").await
1272 }
1273
1274 // ── Analytics ──
1275
1276 /// Get analytics data (timeseries, period comparison, top projects).
1277 pub(crate) async fn get_analytics(
1278 &self,
1279 user_id: &str,
1280 range: &str,
1281 ) -> anyhow::Result<AnalyticsData> {
1282 let url = format!("{}/api/internal/creator/analytics", self.base_url);
1283 let resp = self
1284 .http
1285 .get(&url)
1286 .bearer_auth(&self.service_token)
1287 .header("X-MNW-Actor", self.actor_header())
1288 .query(&[("user_id", user_id), ("range", range)])
1289 .send()
1290 .await?;
1291
1292 json_response(resp, "get_analytics").await
1293 }
1294
1295 /// Get recent seller transactions.
1296 pub(crate) async fn get_transactions(&self, user_id: &str) -> anyhow::Result<Vec<Transaction>> {
1297 let url = format!("{}/api/internal/creator/transactions", self.base_url);
1298 let resp = self
1299 .http
1300 .get(&url)
1301 .bearer_auth(&self.service_token)
1302 .header("X-MNW-Actor", self.actor_header())
1303 .query(&[("user_id", user_id)])
1304 .send()
1305 .await?;
1306
1307 json_response(resp, "get_transactions").await
1308 }
1309
1310 /// Export sales as CSV string.
1311 pub(crate) async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result<ExportResult> {
1312 let url = format!("{}/api/internal/creator/export/sales", self.base_url);
1313 let resp = self
1314 .http
1315 .get(&url)
1316 .bearer_auth(&self.service_token)
1317 .header("X-MNW-Actor", self.actor_header())
1318 .query(&[("user_id", user_id)])
1319 .send()
1320 .await?;
1321
1322 json_response(resp, "export_sales_csv").await
1323 }
1324
1325 // ── SSH keys ──
1326
1327 /// Authorize a git operation and get the on-disk repo path.
1328 pub(crate) async fn git_authorize(
1329 &self,
1330 user_id: &str,
1331 operation: &str,
1332 owner: &str,
1333 repo_name: &str,
1334 ) -> anyhow::Result<GitAuthResponse> {
1335 let url = format!("{}/api/internal/git/authorize", self.base_url);
1336 let resp = self
1337 .http
1338 .post(&url)
1339 .bearer_auth(&self.service_token)
1340 .header("X-MNW-Actor", self.actor_header())
1341 .json(&serde_json::json!({
1342 "user_id": user_id,
1343 "operation": operation,
1344 "owner": owner,
1345 "repo_name": repo_name,
1346 }))
1347 .send()
1348 .await?;
1349
1350 if !resp.status().is_success() {
1351 let status = resp.status();
1352 let body = resp.text().await.unwrap_or_else(|e| {
1353 tracing::warn!(error = %e, "failed to read git_authorize error body");
1354 String::new()
1355 });
1356 // Parse JSON error if available, fall back to status text
1357 let msg = serde_json::from_str::<serde_json::Value>(&body)
1358 .ok()
1359 .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
1360 .unwrap_or_else(|| format!("HTTP {status}"));
1361 anyhow::bail!("{msg}");
1362 }
1363
1364 Ok(resp.json().await?)
1365 }
1366
1367 /// List registered SSH keys for a user.
1368 pub(crate) async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> {
1369 let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
1370 let resp = self
1371 .http
1372 .get(&url)
1373 .bearer_auth(&self.service_token)
1374 .header("X-MNW-Actor", self.actor_header())
1375 .query(&[("user_id", user_id)])
1376 .send()
1377 .await?;
1378
1379 json_response(resp, "list_ssh_keys").await
1380 }
1381
1382 // ── Tags ──
1383
1384 pub(crate) async fn list_item_tags(
1385 &self,
1386 user_id: &str,
1387 item_id: &str,
1388 ) -> anyhow::Result<Vec<TagInfo>> {
1389 let url = format!(
1390 "{}/api/internal/creator/items/{}/tags",
1391 self.base_url, item_id
1392 );
1393 let resp = self
1394 .http
1395 .get(&url)
1396 .bearer_auth(&self.service_token)
1397 .header("X-MNW-Actor", self.actor_header())
1398 .query(&[("user_id", user_id)])
1399 .send()
1400 .await?;
1401 json_response(resp, "list_item_tags").await
1402 }
1403
1404 pub(crate) async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
1405 let url = format!("{}/api/internal/tags/search", self.base_url);
1406 let resp = self
1407 .http
1408 .get(&url)
1409 .bearer_auth(&self.service_token)
1410 .header("X-MNW-Actor", self.actor_header())
1411 .query(&[("q", query)])
1412 .send()
1413 .await?;
1414 json_response(resp, "search_tags").await
1415 }
1416
1417 pub(crate) async fn add_item_tag(
1418 &self,
1419 user_id: &str,
1420 item_id: &str,
1421 tag_id: &str,
1422 ) -> anyhow::Result<()> {
1423 let url = format!("{}/api/internal/creator/items/tags", self.base_url);
1424 let resp = self
1425 .http
1426 .post(&url)
1427 .bearer_auth(&self.service_token)
1428 .header("X-MNW-Actor", self.actor_header())
1429 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1430 .send()
1431 .await?;
1432 empty_response(resp, "add_item_tag").await
1433 }
1434
1435 // Unused by the TUI today; kept so the client mirrors the full
1436 // /api/internal surface rather than only the paths one caller happens to hit.
1437 #[allow(dead_code)]
1438 pub(crate) async fn remove_item_tag(
1439 &self,
1440 user_id: &str,
1441 item_id: &str,
1442 tag_id: &str,
1443 ) -> anyhow::Result<()> {
1444 let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
1445 let resp = self
1446 .http
1447 .post(&url)
1448 .bearer_auth(&self.service_token)
1449 .header("X-MNW-Actor", self.actor_header())
1450 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1451 .send()
1452 .await?;
1453 empty_response(resp, "remove_item_tag").await
1454 }
1455
1456 // ── Broadcast ──
1457
1458 pub(crate) async fn send_broadcast(
1459 &self,
1460 user_id: &str,
1461 subject: &str,
1462 body: &str,
1463 ) -> anyhow::Result<BroadcastResult> {
1464 let url = format!("{}/api/internal/creator/broadcast", self.base_url);
1465 let resp = self
1466 .http
1467 .post(&url)
1468 .bearer_auth(&self.service_token)
1469 .header("X-MNW-Actor", self.actor_header())
1470 .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
1471 .send()
1472 .await?;
1473 json_response(resp, "send_broadcast").await
1474 }
1475
1476 // ── Tiers ──
1477
1478 pub(crate) async fn list_tiers(
1479 &self,
1480 user_id: &str,
1481 project_id: &str,
1482 ) -> anyhow::Result<Vec<TierInfo>> {
1483 let url = format!(
1484 "{}/api/internal/creator/projects/{}/tiers",
1485 self.base_url, project_id
1486 );
1487 let resp = self
1488 .http
1489 .get(&url)
1490 .bearer_auth(&self.service_token)
1491 .header("X-MNW-Actor", self.actor_header())
1492 .query(&[("user_id", user_id)])
1493 .send()
1494 .await?;
1495 json_response(resp, "list_tiers").await
1496 }
1497
1498 // ── Collections ──
1499
1500 pub(crate) async fn list_collections(
1501 &self,
1502 user_id: &str,
1503 ) -> anyhow::Result<Vec<CollectionInfo>> {
1504 let url = format!("{}/api/internal/creator/collections", self.base_url);
1505 let resp = self
1506 .http
1507 .get(&url)
1508 .bearer_auth(&self.service_token)
1509 .header("X-MNW-Actor", self.actor_header())
1510 .query(&[("user_id", user_id)])
1511 .send()
1512 .await?;
1513 json_response(resp, "list_collections").await
1514 }
1515
1516 #[allow(dead_code)]
1517 pub(crate) async fn create_collection(
1518 &self,
1519 user_id: &str,
1520 slug: &str,
1521 title: &str,
1522 ) -> anyhow::Result<serde_json::Value> {
1523 let url = format!("{}/api/internal/creator/collections", self.base_url);
1524 let resp = self
1525 .http
1526 .post(&url)
1527 .bearer_auth(&self.service_token)
1528 .header("X-MNW-Actor", self.actor_header())
1529 .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1530 .send()
1531 .await?;
1532 json_response(resp, "create_collection").await
1533 }
1534
1535 #[allow(dead_code)]
1536 pub(crate) async fn delete_collection(
1537 &self,
1538 user_id: &str,
1539 collection_id: &str,
1540 ) -> anyhow::Result<()> {
1541 let url = format!(
1542 "{}/api/internal/creator/collections/{}",
1543 self.base_url, collection_id
1544 );
1545 let resp = self
1546 .http
1547 .delete(&url)
1548 .bearer_auth(&self.service_token)
1549 .header("X-MNW-Actor", self.actor_header())
1550 .query(&[("user_id", user_id)])
1551 .send()
1552 .await?;
1553 empty_response(resp, "delete_collection").await
1554 }
1555
1556 // ── Custom Domains ──
1557
1558 pub(crate) async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1559 let url = format!("{}/api/internal/creator/domain", self.base_url);
1560 let resp = self
1561 .http
1562 .get(&url)
1563 .bearer_auth(&self.service_token)
1564 .header("X-MNW-Actor", self.actor_header())
1565 .query(&[("user_id", user_id)])
1566 .send()
1567 .await?;
1568 let val: serde_json::Value = json_response(resp, "get_domain").await?;
1569 if val.is_null() {
1570 return Ok(None);
1571 }
1572 Ok(serde_json::from_value(val).ok())
1573 }
1574
1575 pub(crate) async fn add_domain(
1576 &self,
1577 user_id: &str,
1578 domain: &str,
1579 ) -> anyhow::Result<DomainInfo> {
1580 let url = format!("{}/api/internal/creator/domain", self.base_url);
1581 let resp = self
1582 .http
1583 .post(&url)
1584 .bearer_auth(&self.service_token)
1585 .header("X-MNW-Actor", self.actor_header())
1586 .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1587 .send()
1588 .await?;
1589 json_response(resp, "add_domain").await
1590 }
1591
1592 pub(crate) async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1593 let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1594 let resp = self
1595 .http
1596 .post(&url)
1597 .bearer_auth(&self.service_token)
1598 .header("X-MNW-Actor", self.actor_header())
1599 .query(&[("user_id", user_id)])
1600 .send()
1601 .await?;
1602 json_response(resp, "verify_domain").await
1603 }
1604
1605 pub(crate) async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1606 let url = format!("{}/api/internal/creator/domain", self.base_url);
1607 let resp = self
1608 .http
1609 .delete(&url)
1610 .bearer_auth(&self.service_token)
1611 .header("X-MNW-Actor", self.actor_header())
1612 .query(&[("user_id", user_id)])
1613 .send()
1614 .await?;
1615 empty_response(resp, "remove_domain").await
1616 }
1617 }
1618