Skip to main content

max / makenotwork

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