Skip to main content

max / makenotwork

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