Skip to main content

max / makenotwork

Split mnw-cli's api.rs by endpoint family 1934 lines: 33 response structs and one MnwApiClient carrying 60 methods, in one file because they all talk to the same server, not because they had anything else in common. Each endpoint family becomes a module with its own types and its own `impl MnwApiClient` block. That works without loosening anything: a descendant module sees its parent's private fields, so `http`, `base_url` and the two tokens stay private to api/mod.rs while every family reaches them. uploads is the one family with real logic rather than transport boilerplate, and it now says so in its own header. json_response and empty_response were byte-identical apart from the final `Ok(...)`. Both now call one `bail_for_status`, which is the part that was actually duplicated: read the body, and let the operator see the server's own reason instead of a bare status code. The facade re-exports what is named from outside api/, which is not the whole type list: a `pub(crate) use` nothing imports is a warning, so the types used only inside their own family are not re-exported. Same 7 tests, all 101 in the crate passing, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 02:19 UTC
Signed with PGP, not checked
Commit: 2e653048935fcb3faca789b4edd68e3f94b9fa5f
Parent: 3aa99af
9 files changed, +1986 insertions, -500 deletions
@@ -1,1934 +1,0 @@
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.
Lines truncated
@@ -1,0 +1,121 @@
1 + //! Analytics: the timeseries and period comparison behind the dashboard, the
2 + //! transaction ledger, and the CSV export of it.
3 +
4 + use super::{MnwApiClient, json_response, revenue_of};
5 + use crate::currency::{Currency, RevenueByCurrency};
6 + use serde::{Deserialize, Serialize};
7 + use std::collections::BTreeMap;
8 +
9 + /// A revenue bucket for analytics timeseries.
10 + #[derive(Debug, Clone, Deserialize, Serialize)]
11 + pub(crate) struct AnalyticsBucket {
12 + pub label: String,
13 + pub revenue_cents: i64,
14 + pub sales_count: i64,
15 + }
16 +
17 + /// Per-project revenue summary.
18 + #[derive(Debug, Clone, Deserialize, Serialize)]
19 + pub(crate) struct ProjectRevenue {
20 + pub id: String,
21 + pub title: String,
22 + /// Revenue in `currency`. See [`Project::revenue_cents`].
23 + pub revenue_cents: i64,
24 + #[serde(default)]
25 + pub currency: Currency,
26 + #[serde(default)]
27 + pub revenue_cents_by_currency: BTreeMap<String, i64>,
28 + }
29 +
30 + impl ProjectRevenue {
31 + /// Revenue across every currency it was earned in.
32 + pub(crate) fn revenue(&self) -> RevenueByCurrency {
33 + revenue_of(
34 + self.revenue_cents,
35 + self.currency,
36 + &self.revenue_cents_by_currency,
37 + )
38 + }
39 + }
40 +
41 + /// Analytics response with timeseries, comparison, and top projects.
42 + #[derive(Debug, Clone, Deserialize, Serialize)]
43 + pub(crate) struct AnalyticsData {
44 + pub buckets: Vec<AnalyticsBucket>,
45 + pub current_revenue_cents: i64,
46 + pub previous_revenue_cents: i64,
47 + pub current_sales: i64,
48 + pub previous_sales: i64,
49 + pub current_followers: i64,
50 + pub previous_followers: i64,
51 + pub top_projects: Vec<ProjectRevenue>,
52 + }
53 +
54 + /// A seller transaction.
55 + #[derive(Debug, Clone, Deserialize, Serialize)]
56 + pub(crate) struct Transaction {
57 + pub id: String,
58 + pub item_title: Option<String>,
59 + pub amount_cents: i32,
60 + pub status: String,
61 + pub created_at: String,
62 + pub completed_at: Option<String>,
63 + }
64 +
65 + /// CSV export result.
66 + #[derive(Debug, Clone, Deserialize, Serialize)]
67 + pub(crate) struct ExportResult {
68 + pub csv: String,
69 + pub row_count: usize,
70 + }
71 +
72 + impl MnwApiClient {
73 + /// Get analytics data (timeseries, period comparison, top projects).
74 + pub(crate) async fn get_analytics(
75 + &self,
76 + user_id: &str,
77 + range: &str,
78 + ) -> anyhow::Result<AnalyticsData> {
79 + let url = format!("{}/api/internal/creator/analytics", self.base_url);
80 + let resp = self
81 + .http
82 + .get(&url)
83 + .bearer_auth(&self.service_token)
84 + .header("X-MNW-Actor", self.actor_header())
85 + .query(&[("user_id", user_id), ("range", range)])
86 + .send()
87 + .await?;
88 +
89 + json_response(resp, "get_analytics").await
90 + }
91 +
92 + /// Get recent seller transactions.
93 + pub(crate) async fn get_transactions(&self, user_id: &str) -> anyhow::Result<Vec<Transaction>> {
94 + let url = format!("{}/api/internal/creator/transactions", self.base_url);
95 + let resp = self
96 + .http
97 + .get(&url)
98 + .bearer_auth(&self.service_token)
99 + .header("X-MNW-Actor", self.actor_header())
100 + .query(&[("user_id", user_id)])
101 + .send()
102 + .await?;
103 +
104 + json_response(resp, "get_transactions").await
105 + }
106 +
107 + /// Export sales as CSV string.
108 + pub(crate) async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result<ExportResult> {
109 + let url = format!("{}/api/internal/creator/export/sales", self.base_url);
110 + let resp = self
111 + .http
112 + .get(&url)
113 + .bearer_auth(&self.service_token)
114 + .header("X-MNW-Actor", self.actor_header())
115 + .query(&[("user_id", user_id)])
116 + .send()
117 + .await?;
118 +
119 + json_response(resp, "export_sales_csv").await
120 + }
121 + }
@@ -1,0 +1,84 @@
1 + //! Custom domains: attach one to a creator, prove ownership, take it away.
2 +
3 + use super::{MnwApiClient, empty_response, json_response};
4 + use serde::{Deserialize, Serialize};
5 +
6 + /// Custom domain info.
7 + #[derive(Debug, Clone, Deserialize, Serialize)]
8 + pub(crate) struct DomainInfo {
9 + pub id: String,
10 + pub domain: String,
11 + pub verified: bool,
12 + pub verification_token: String,
13 + pub instructions: Option<String>,
14 + }
15 +
16 + /// Domain verification result.
17 + #[derive(Debug, Deserialize)]
18 + #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not
19 + pub(crate) struct DomainVerifyResult {
20 + pub verified: bool,
21 + pub message: String,
22 + }
23 +
24 + impl MnwApiClient {
25 + pub(crate) async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
26 + let url = format!("{}/api/internal/creator/domain", self.base_url);
27 + let resp = self
28 + .http
29 + .get(&url)
30 + .bearer_auth(&self.service_token)
31 + .header("X-MNW-Actor", self.actor_header())
32 + .query(&[("user_id", user_id)])
33 + .send()
34 + .await?;
35 + let val: serde_json::Value = json_response(resp, "get_domain").await?;
36 + if val.is_null() {
37 + return Ok(None);
38 + }
39 + Ok(serde_json::from_value(val).ok())
40 + }
41 +
42 + pub(crate) async fn add_domain(
43 + &self,
44 + user_id: &str,
45 + domain: &str,
46 + ) -> anyhow::Result<DomainInfo> {
47 + let url = format!("{}/api/internal/creator/domain", self.base_url);
48 + let resp = self
49 + .http
50 + .post(&url)
51 + .bearer_auth(&self.service_token)
52 + .header("X-MNW-Actor", self.actor_header())
53 + .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
54 + .send()
55 + .await?;
56 + json_response(resp, "add_domain").await
57 + }
58 +
59 + pub(crate) async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
60 + let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
61 + let resp = self
62 + .http
63 + .post(&url)
64 + .bearer_auth(&self.service_token)
65 + .header("X-MNW-Actor", self.actor_header())
66 + .query(&[("user_id", user_id)])
67 + .send()
68 + .await?;
69 + json_response(resp, "verify_domain").await
70 + }
71 +
72 + pub(crate) async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
73 + let url = format!("{}/api/internal/creator/domain", self.base_url);
74 + let resp = self
75 + .http
76 + .delete(&url)
77 + .bearer_auth(&self.service_token)
78 + .header("X-MNW-Actor", self.actor_header())
79 + .query(&[("user_id", user_id)])
80 + .send()
81 + .await?;
82 + empty_response(resp, "remove_domain").await
83 + }
84 + }
@@ -1,0 +1,220 @@
1 + //! Git repositories and the SSH keys that reach them.
2 + //!
3 + //! Addressed by repo NAME, matching the CLI's own vocabulary. The browser API
4 + //! keys on the repo id because a page has the row loaded; a person at a
5 + //! terminal does not.
6 +
7 + use super::{MnwApiClient, empty_response, json_response};
8 + use serde::{Deserialize, Serialize};
9 +
10 + /// A repository as `repo list` renders it.
11 + #[derive(Debug, Clone, Deserialize, Serialize)]
12 + pub(crate) struct CliRepo {
13 + pub name: String,
14 + pub visibility: String,
15 + pub description: String,
16 + pub created_at: String,
17 + }
18 +
19 + /// A repository plus its issue counts, for `repo info`.
20 + #[derive(Debug, Clone, Deserialize, Serialize)]
21 + pub(crate) struct CliRepoInfo {
22 + pub name: String,
23 + pub visibility: String,
24 + pub description: String,
25 + pub created_at: String,
26 + pub open_issues: i64,
27 + pub closed_issues: i64,
28 + }
29 +
30 + /// An SSH key as `key list` renders it.
31 + #[derive(Debug, Clone, Deserialize, Serialize)]
32 + pub(crate) struct CliSshKey {
33 + pub fingerprint: String,
34 + pub label: String,
35 + pub created_at: String,
36 + }
37 +
38 + /// A registered SSH key.
39 + #[derive(Debug, Clone, Deserialize, Serialize)]
40 + pub(crate) struct SshKeyInfo {
41 + pub id: String,
42 + pub label: String,
43 + pub fingerprint: String,
44 + pub created_at: String,
45 + }
46 +
47 + /// Response from the git authorize endpoint.
48 + #[derive(Debug, Deserialize)]
49 + pub(crate) struct GitAuthResponse {
50 + pub repo_path: String,
51 + }
52 +
53 + impl MnwApiClient {
54 + /// Authorize a git operation and get the on-disk repo path.
55 + pub(crate) async fn git_authorize(
56 + &self,
57 + user_id: &str,
58 + operation: &str,
59 + owner: &str,
60 + repo_name: &str,
61 + ) -> anyhow::Result<GitAuthResponse> {
62 + let url = format!("{}/api/internal/git/authorize", self.base_url);
63 + let resp = self
64 + .http
65 + .post(&url)
66 + .bearer_auth(&self.service_token)
67 + .header("X-MNW-Actor", self.actor_header())
68 + .json(&serde_json::json!({
69 + "user_id": user_id,
70 + "operation": operation,
71 + "owner": owner,
72 + "repo_name": repo_name,
73 + }))
74 + .send()
75 + .await?;
76 +
77 + if !resp.status().is_success() {
78 + let status = resp.status();
79 + let body = resp.text().await.unwrap_or_else(|e| {
80 + tracing::warn!(error = %e, "failed to read git_authorize error body");
81 + String::new()
82 + });
83 + // Parse JSON error if available, fall back to status text
84 + let msg = serde_json::from_str::<serde_json::Value>(&body)
85 + .ok()
86 + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
87 + .unwrap_or_else(|| format!("HTTP {status}"));
88 + anyhow::bail!("{msg}");
89 + }
90 +
91 + Ok(resp.json().await?)
92 + }
93 +
94 + /// List registered SSH keys for a user.
95 + pub(crate) async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> {
96 + let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
97 + let resp = self
98 + .http
99 + .get(&url)
100 + .bearer_auth(&self.service_token)
101 + .header("X-MNW-Actor", self.actor_header())
102 + .query(&[("user_id", user_id)])
103 + .send()
104 + .await?;
105 +
106 + json_response(resp, "list_ssh_keys").await
107 + }
108 +
109 + pub(crate) async fn repo_list(&self, user_id: &str) -> anyhow::Result<Vec<CliRepo>> {
110 + let url = format!("{}/api/internal/creator/repos", self.base_url);
111 + let resp = self
112 + .http
113 + .get(&url)
114 + .bearer_auth(&self.service_token)
115 + .header("X-MNW-Actor", self.actor_header())
116 + .query(&[("user_id", user_id)])
117 + .send()
118 + .await?;
119 + json_response(resp, "repo_list").await
120 + }
121 +
122 + pub(crate) async fn repo_info(&self, user_id: &str, name: &str) -> anyhow::Result<CliRepoInfo> {
123 + let url = format!("{}/api/internal/creator/repos/{name}", self.base_url);
124 + let resp = self
125 + .http
126 + .get(&url)
127 + .bearer_auth(&self.service_token)
128 + .header("X-MNW-Actor", self.actor_header())
129 + .query(&[("user_id", user_id)])
130 + .send()
131 + .await?;
132 + json_response(resp, "repo_info").await
133 + }
134 +
135 + pub(crate) async fn repo_set_visibility(
136 + &self,
137 + user_id: &str,
138 + name: &str,
139 + visibility: &str,
140 + ) -> anyhow::Result<()> {
141 + let url = format!(
142 + "{}/api/internal/creator/repos/{name}/visibility",
143 + self.base_url
144 + );
145 + let resp = self
146 + .http
147 + .put(&url)
148 + .bearer_auth(&self.service_token)
149 + .header("X-MNW-Actor", self.actor_header())
150 + .query(&[("user_id", user_id)])
151 + .json(&serde_json::json!({ "visibility": visibility }))
152 + .send()
153 + .await?;
154 + empty_response(resp, "repo_set_visibility").await
155 + }
156 +
157 + pub(crate) async fn repo_set_description(
158 + &self,
159 + user_id: &str,
160 + name: &str,
161 + description: &str,
162 + ) -> anyhow::Result<()> {
163 + let url = format!(
164 + "{}/api/internal/creator/repos/{name}/description",
165 + self.base_url
166 + );
167 + let resp = self
168 + .http
169 + .put(&url)
170 + .bearer_auth(&self.service_token)
171 + .header("X-MNW-Actor", self.actor_header())
172 + .query(&[("user_id", user_id)])
173 + .json(&serde_json::json!({ "description": description }))
174 + .send()
175 + .await?;
176 + empty_response(resp, "repo_set_description").await
177 + }
178 +
179 + pub(crate) async fn repo_delete(&self, user_id: &str, name: &str) -> anyhow::Result<()> {
180 + let url = format!("{}/api/internal/creator/repos/{name}", self.base_url);
181 + let resp = self
182 + .http
183 + .delete(&url)
184 + .bearer_auth(&self.service_token)
185 + .header("X-MNW-Actor", self.actor_header())
186 + .query(&[("user_id", user_id)])
187 + .send()
188 + .await?;
189 + empty_response(resp, "repo_delete").await
190 + }
191 +
192 + pub(crate) async fn key_list(&self, user_id: &str) -> anyhow::Result<Vec<CliSshKey>> {
193 + let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
194 + let resp = self
195 + .http
196 + .get(&url)
197 + .bearer_auth(&self.service_token)
198 + .header("X-MNW-Actor", self.actor_header())
199 + .query(&[("user_id", user_id)])
200 + .send()
201 + .await?;
202 + json_response(resp, "key_list").await
203 + }
204 +
205 + pub(crate) async fn key_remove(&self, user_id: &str, fingerprint: &str) -> anyhow::Result<()> {
206 + let url = format!(
207 + "{}/api/internal/creator/ssh-keys/{fingerprint}",
208 + self.base_url
209 + );
210 + let resp = self
211 + .http
212 + .delete(&url)
213 + .bearer_auth(&self.service_token)
214 + .header("X-MNW-Actor", self.actor_header())
215 + .query(&[("user_id", user_id)])
216 + .send()
217 + .await?;
218 + empty_response(resp, "key_remove").await
219 + }
220 + }
@@ -1,0 +1,299 @@
1 + //! Items: create, read, update, publish, and the tags an item carries.
2 +
3 + use super::{MnwApiClient, empty_response, json_response};
4 + use serde::{Deserialize, Serialize};
5 +
6 + /// Response from the create-item internal endpoint.
7 + #[derive(Debug, Deserialize)]
8 + #[allow(dead_code)]
9 + pub(crate) struct ItemCreated {
10 + pub item_id: String,
11 + pub project_id: String,
12 + }
13 +
14 + /// Full item detail returned from the get/update endpoints.
15 + #[derive(Debug, Clone, Deserialize, Serialize)]
16 + pub(crate) struct ItemDetail {
17 + pub id: String,
18 + pub title: String,
19 + pub description: Option<String>,
20 + pub price_cents: i32,
21 + pub item_type: String,
22 + pub is_public: bool,
23 + pub slug: String,
24 + pub sort_order: i32,
25 + pub sales_count: i32,
26 + pub download_count: i32,
27 + pub play_count: i32,
28 + pub pwyw_enabled: bool,
29 + pub pwyw_min_cents: Option<i32>,
30 + pub has_audio: bool,
31 + pub has_cover: bool,
32 + pub created_at: String,
33 + pub updated_at: String,
34 + }
35 +
36 + /// A version of an item.
37 + #[derive(Debug, Clone, Deserialize, Serialize)]
38 + #[allow(
39 + clippy::struct_field_names,
40 + reason = "version_number mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
41 + )]
42 + pub(crate) struct Version {
43 + pub id: String,
44 + pub version_number: String,
45 + pub changelog: Option<String>,
46 + pub file_name: Option<String>,
47 + pub file_size_bytes: Option<i64>,
48 + pub download_count: i32,
49 + pub is_current: bool,
50 + pub created_at: String,
51 + }
52 +
53 + /// A tag on an item or from search.
54 + #[derive(Debug, Clone, Deserialize, Serialize)]
55 + pub(crate) struct TagInfo {
56 + pub id: String,
57 + pub name: String,
58 + pub slug: String,
59 + pub is_primary: bool,
60 + }
61 +
62 + impl MnwApiClient {
63 + /// Create an item in a project.
64 + pub(crate) async fn create_item(
65 + &self,
66 + user_id: &str,
67 + project_id: &str,
68 + title: &str,
69 + item_type: &str,
70 + price_cents: i32,
71 + ) -> anyhow::Result<ItemCreated> {
72 + let url = format!("{}/api/internal/creator/items", self.base_url);
73 + let resp = self
74 + .http
75 + .post(&url)
76 + .bearer_auth(&self.service_token)
77 + .header("X-MNW-Actor", self.actor_header())
78 + .json(&serde_json::json!({
79 + "user_id": user_id,
80 + "project_id": project_id,
81 + "title": title,
82 + "item_type": item_type,
83 + "price_cents": price_cents,
84 + }))
85 + .send()
86 + .await?;
87 +
88 + json_response(resp, "create_item").await
89 + }
90 +
91 + /// Fetch full item detail.
92 + pub(crate) async fn get_item_detail(
93 + &self,
94 + user_id: &str,
95 + item_id: &str,
96 + ) -> anyhow::Result<ItemDetail> {
97 + let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
98 + let resp = self
99 + .http
100 + .get(&url)
101 + .bearer_auth(&self.service_token)
102 + .header("X-MNW-Actor", self.actor_header())
103 + .query(&[("user_id", user_id)])
104 + .send()
105 + .await?;
106 +
107 + json_response(resp, "get_item_detail").await
108 + }
109 +
110 + /// Update item fields. Only non-None fields are changed.
111 + pub(crate) async fn update_item(
112 + &self,
113 + user_id: &str,
114 + item_id: &str,
115 + title: Option<&str>,
116 + description: Option<&str>,
117 + price_cents: Option<i32>,
118 + is_public: Option<bool>,
119 + ) -> anyhow::Result<ItemDetail> {
120 + let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
121 + let mut body = serde_json::json!({ "user_id": user_id });
122 + if let Some(t) = title {
123 + body["title"] = serde_json::Value::String(t.to_string());
124 + }
125 + if let Some(d) = description {
126 + body["description"] = serde_json::Value::String(d.to_string());
127 + }
128 + if let Some(p) = price_cents {
129 + body["price_cents"] = serde_json::json!(p);
130 + }
131 + if let Some(v) = is_public {
132 + body["is_public"] = serde_json::json!(v);
133 + }
134 +
135 + let resp = self
136 + .http
137 + .put(&url)
138 + .bearer_auth(&self.service_token)
139 + .header("X-MNW-Actor", self.actor_header())
140 + .json(&body)
141 + .send()
142 + .await?;
143 +
144 + json_response(resp, "update_item").await
145 + }
146 +
147 + /// Delete an item permanently.
148 + pub(crate) async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> {
149 + let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
150 + let resp = self
151 + .http
152 + .delete(&url)
153 + .bearer_auth(&self.service_token)
154 + .header("X-MNW-Actor", self.actor_header())
155 + .query(&[("user_id", user_id)])
156 + .send()
157 + .await?;
158 +
159 + empty_response(resp, "delete_item").await
160 + }
161 +
162 + /// Publish an item (set is_public=true).
163 + pub(crate) async fn publish_item(
164 + &self,
165 + user_id: &str,
166 + item_id: &str,
167 + ) -> anyhow::Result<ItemDetail> {
168 + let url = format!(
169 + "{}/api/internal/creator/items/{}/publish",
170 + self.base_url, item_id
171 + );
172 + let resp = self
173 + .http
174 + .post(&url)
175 + .bearer_auth(&self.service_token)
176 + .header("X-MNW-Actor", self.actor_header())
177 + .json(&serde_json::json!({ "user_id": user_id }))
178 + .send()
179 + .await?;
180 +
181 + json_response(resp, "publish_item").await
182 + }
183 +
184 + /// Unpublish an item (set is_public=false).
185 + pub(crate) async fn unpublish_item(
186 + &self,
187 + user_id: &str,
188 + item_id: &str,
189 + ) -> anyhow::Result<ItemDetail> {
190 + let url = format!(
191 + "{}/api/internal/creator/items/{}/unpublish",
192 + self.base_url, item_id
193 + );
194 + let resp = self
195 + .http
196 + .post(&url)
197 + .bearer_auth(&self.service_token)
198 + .header("X-MNW-Actor", self.actor_header())
199 + .json(&serde_json::json!({ "user_id": user_id }))
200 + .send()
201 + .await?;
202 +
203 + json_response(resp, "unpublish_item").await
204 + }
205 +
206 + /// Fetch versions for an item.
207 + pub(crate) async fn get_item_versions(
208 + &self,
209 + user_id: &str,
210 + item_id: &str,
211 + ) -> anyhow::Result<Vec<Version>> {
212 + let url = format!(
213 + "{}/api/internal/creator/items/{}/versions",
214 + self.base_url, item_id
215 + );
216 + let resp = self
217 + .http
218 + .get(&url)
219 + .bearer_auth(&self.service_token)
220 + .header("X-MNW-Actor", self.actor_header())
221 + .query(&[("user_id", user_id)])
222 + .send()
223 + .await?;
224 +
225 + json_response(resp, "get_item_versions").await
226 + }
227 +
228 + pub(crate) async fn list_item_tags(
229 + &self,
230 + user_id: &str,
231 + item_id: &str,
232 + ) -> anyhow::Result<Vec<TagInfo>> {
233 + let url = format!(
234 + "{}/api/internal/creator/items/{}/tags",
235 + self.base_url, item_id
236 + );
237 + let resp = self
238 + .http
239 + .get(&url)
240 + .bearer_auth(&self.service_token)
241 + .header("X-MNW-Actor", self.actor_header())
242 + .query(&[("user_id", user_id)])
243 + .send()
244 + .await?;
245 + json_response(resp, "list_item_tags").await
246 + }
247 +
248 + pub(crate) async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
249 + let url = format!("{}/api/internal/tags/search", self.base_url);
250 + let resp = self
251 + .http
252 + .get(&url)
253 + .bearer_auth(&self.service_token)
254 + .header("X-MNW-Actor", self.actor_header())
255 + .query(&[("q", query)])
256 + .send()
257 + .await?;
258 + json_response(resp, "search_tags").await
259 + }
260 +
261 + pub(crate) async fn add_item_tag(
262 + &self,
263 + user_id: &str,
264 + item_id: &str,
265 + tag_id: &str,
266 + ) -> anyhow::Result<()> {
267 + let url = format!("{}/api/internal/creator/items/tags", self.base_url);
268 + let resp = self
269 + .http
270 + .post(&url)
271 + .bearer_auth(&self.service_token)
272 + .header("X-MNW-Actor", self.actor_header())
273 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
274 + .send()
275 + .await?;
276 + empty_response(resp, "add_item_tag").await
277 + }
278 +
279 + // Unused by the TUI today; kept so the client mirrors the full
280 + // /api/internal surface rather than only the paths one caller happens to hit.
281 + #[allow(dead_code)]
282 + pub(crate) async fn remove_item_tag(
283 + &self,
284 + user_id: &str,
285 + item_id: &str,
286 + tag_id: &str,
287 + ) -> anyhow::Result<()> {
288 + let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
289 + let resp = self
290 + .http
291 + .post(&url)
292 + .bearer_auth(&self.service_token)
293 + .header("X-MNW-Actor", self.actor_header())
294 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
295 + .send()
296 + .await?;
297 + empty_response(resp, "remove_item_tag").await
298 + }
299 + }
@@ -1,0 +1,273 @@
1 + //! HTTP client for the MNW internal API.
2 + //!
3 + //! One client, one method per endpoint, split into a module per endpoint
4 + //! family. Each family adds its own `impl MnwApiClient` block: a descendant
5 + //! module sees the parent's private fields, so the token and base-URL state
6 + //! stays private to this module and every family still reaches it.
7 +
8 + use serde::{Deserialize, Serialize};
9 + use std::collections::BTreeMap;
10 +
11 + use crate::currency::{Currency, RevenueByCurrency};
12 +
13 + mod analytics;
14 + mod domains;
15 + mod git;
16 + mod items;
17 + mod projects;
18 + mod storefront;
19 + mod uploads;
20 +
21 + pub(crate) use analytics::{AnalyticsData, Transaction};
22 + pub(crate) use git::SshKeyInfo;
23 + pub(crate) use items::{ItemDetail, TagInfo, Version};
24 + pub(crate) use projects::{CreatorStats, Item, Project, StorageInfo};
25 + pub(crate) use storefront::{BlogPost, CollectionInfo, LicenseKey, PromoCode, TierInfo};
26 + pub(crate) use uploads::MULTIPART_THRESHOLD_BYTES;
27 +
28 + /// User info returned from the SSH key lookup endpoint.
29 + #[derive(Debug, Clone, Deserialize, Serialize)]
30 + pub(crate) struct UserInfo {
31 + pub user_id: String,
32 + pub username: String,
33 + pub display_name: Option<String>,
34 + pub creator_tier: Option<String>,
35 + pub can_create_projects: bool,
36 + pub suspended: bool,
37 + /// Signed actor assertion the server mints at lookup; forwarded as
38 + /// `X-MNW-Actor` on internal calls so the server derives identity from an
39 + /// SSH-authenticated token rather than a caller-supplied `user_id`.
40 + #[serde(default)]
41 + pub actor_token: String,
42 + /// The currency this creator is paid in. Every amount that is theirs —
43 + /// their prices, their period totals — renders in it. Defaulted for the
44 + /// window where a new CLI talks to a server that predates the field.
45 + #[serde(default)]
46 + pub settlement_currency: Currency,
47 + /// The theme this creator picked, as `makeover::ThemeSelection` encodes it:
48 + /// a theme id, or `"system"` to follow the terminal.
49 + ///
50 + /// `None` where the server did not send the field, which is every server
51 + /// today — it is not stored yet, and adding it is filed as its own task.
52 + /// Absent and unset are the same thing until then, and both follow the
53 + /// terminal, so this reads correctly on both sides of that change.
54 + #[serde(default)]
55 + pub theme_id: Option<String>,
56 + }
57 +
58 + /// Read a revenue figure that arrives as both a dominant amount and a full
59 + /// per-currency map.
60 + ///
61 + /// The map is authoritative when present. It is empty in two cases that must
62 + /// not render blank: a server too old to send it, and a project with no sales.
63 + /// Both fall back to the single pair.
64 + ///
65 + /// A zero amount then reduces to nothing, and the render falls through to the
66 + /// viewer's own currency. That is the right symbol for it: with no sales there
67 + /// is no currency the money is *in*, and the `currency` the server names for an
68 + /// empty total is its own default rather than a fact about the project.
69 + pub(super) fn revenue_of(
70 + cents: i64,
71 + currency: Currency,
72 + by_currency: &BTreeMap<String, i64>,
73 + ) -> RevenueByCurrency {
74 + if by_currency.is_empty() {
75 + RevenueByCurrency::from_rows([(currency, cents)])
76 + } else {
77 + RevenueByCurrency::from_wire_map(by_currency)
78 + }
79 + }
80 +
81 + /// Bail with the server's own error detail unless the response succeeded.
82 + ///
83 + /// The body is worth the extra read: the API answers a rejected request with a
84 + /// reason, and without this the operator sees a bare status code.
85 + async fn bail_for_status(
86 + resp: reqwest::Response,
87 + context: &str,
88 + ) -> anyhow::Result<reqwest::Response> {
89 + if resp.status().is_success() {
90 + return Ok(resp);
91 + }
92 + let status = resp.status();
93 + let body = resp.text().await.unwrap_or_else(|e| {
94 + tracing::warn!(error = %e, %context, "failed to read error response body");
95 + String::new()
96 + });
97 + if body.is_empty() {
98 + anyhow::bail!("{context} failed: HTTP {status}");
99 + }
100 + anyhow::bail!("{context} failed: HTTP {status}, {body}");
101 + }
102 +
103 + /// Check response status and deserialize JSON body, or bail with error details.
104 + pub(super) async fn json_response<T: serde::de::DeserializeOwned>(
105 + resp: reqwest::Response,
106 + context: &str,
107 + ) -> anyhow::Result<T> {
108 + Ok(bail_for_status(resp, context).await?.json().await?)
109 + }
110 +
111 + /// Check response status for success, or bail with error details.
112 + pub(super) async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
113 + bail_for_status(resp, context).await?;
114 + Ok(())
115 + }
116 +
117 + /// Client for calling MNW internal API endpoints.
118 + #[derive(Clone)]
119 + pub(crate) struct MnwApiClient {
120 + http: reqwest::Client,
121 + base_url: String,
122 + service_token: String,
123 + /// Set once per session from the SSH-key-lookup response; forwarded on
124 + /// internal creator calls as `X-MNW-Actor`.
125 + actor_token: Option<String>,
126 + }
127 +
128 + impl MnwApiClient {
129 + pub(crate) fn new(base_url: String, service_token: String) -> Self {
130 + let http = crate::tls::builder()
131 + .timeout(std::time::Duration::from_secs(5))
132 + .build()
133 + .expect("failed to build HTTP client");
134 +
135 + Self {
136 + http,
137 + base_url,
138 + service_token,
139 + actor_token: None,
140 + }
141 + }
142 +
143 + /// Record the actor assertion for the authenticated session. Subsequent
144 + /// internal calls forward it so the server can verify the acting identity.
145 + pub(crate) fn set_actor_token(&mut self, token: String) {
146 + self.actor_token = Some(token);
147 + }
148 +
149 + /// The `X-MNW-Actor` header value for internal calls (empty before lookup).
150 + fn actor_header(&self) -> &str {
151 + self.actor_token.as_deref().unwrap_or("")
152 + }
153 +
154 + /// Look up a user by SSH key fingerprint.
155 + /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
156 + pub(crate) async fn lookup_ssh_key(
157 + &self,
158 + fingerprint: &str,
159 + ) -> anyhow::Result<Option<UserInfo>> {
160 + let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
161 + let resp = self
162 + .http
163 + .get(&url)
164 + .bearer_auth(&self.service_token)
165 + .header("X-MNW-Actor", self.actor_header())
166 + .query(&[("fingerprint", fingerprint)])
167 + .send()
168 + .await?;
169 +
170 + if resp.status() == reqwest::StatusCode::NOT_FOUND {
171 + return Ok(None);
172 + }
173 +
174 + if !resp.status().is_success() {
175 + anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
176 + }
177 +
178 + let info: UserInfo = resp.json().await?;
179 + Ok(Some(info))
180 + }
181 + }
182 +
183 + #[cfg(test)]
184 + mod tests {
185 + use super::analytics::ProjectRevenue;
186 + use super::*;
187 +
188 + /// The shape `/api/internal/creator/projects` sends today.
189 + fn project_json(extra: &str) -> String {
190 + format!(
191 + r#"{{"id":"p1","slug":"s","title":"T","project_type":"music",
192 + "is_public":true,"item_count":2,"revenue_cents":90000{extra}}}"#
193 + )
194 + }
195 +
196 + #[test]
197 + fn a_project_renders_the_currency_the_server_named() {
198 + let p: Project = serde_json::from_str(&project_json(
199 + r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000}"#,
200 + ))
201 + .unwrap();
202 + assert_eq!(p.currency, Currency::Gbp);
203 + assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00");
204 + }
205 +
206 + #[test]
207 + fn a_project_spanning_two_currencies_shows_both() {
208 + // The whole point of the task: never one of them, never their sum.
209 + let p: Project = serde_json::from_str(&project_json(
210 + r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000,"usd":12000}"#,
211 + ))
212 + .unwrap();
213 + assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00 + $120.00");
214 + assert_eq!(
215 + p.revenue().display_compact(Currency::Usd),
216 + "\u{a3}900.00 +1"
217 + );
218 + }
219 +
220 + #[test]
221 + fn a_response_without_the_currency_fields_still_parses_as_usd() {
222 + // A new CLI against a server that predates the settlement-currency pass
223 + // must render exactly what it always did, not fail to load the screen.
224 + let p: Project = serde_json::from_str(&project_json("")).unwrap();
225 + assert_eq!(p.currency, Currency::Usd);
226 + assert_eq!(p.revenue().display(Currency::Usd), "$900.00");
227 + }
228 +
229 + #[test]
230 + fn a_project_with_no_sales_renders_zero_in_the_viewers_currency() {
231 + // An empty cell here would read as "no data" rather than "no revenue".
232 + // The `currency` the server names on an empty total is its own default,
233 + // so the viewer's own is what the zero renders in.
234 + let p: Project = serde_json::from_str(
235 + r#"{"id":"p1","slug":"s","title":"T","project_type":"music","is_public":true,
236 + "item_count":0,"revenue_cents":0,"currency":"usd","revenue_cents_by_currency":{}}"#,
237 + )
238 + .unwrap();
239 + assert_eq!(p.revenue().display(Currency::Gbp), "\u{a3}0");
240 + assert_eq!(p.revenue().display_compact(Currency::Gbp), "\u{a3}0");
241 + }
242 +
243 + #[test]
244 + fn the_login_lookup_carries_the_viewers_currency() {
245 + let u: UserInfo = serde_json::from_str(
246 + r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":"basic",
247 + "can_create_projects":true,"suspended":false,"actor_token":"t",
248 + "settlement_currency":"cad"}"#,
249 + )
250 + .unwrap();
251 + assert_eq!(u.settlement_currency, Currency::Cad);
252 + }
253 +
254 + #[test]
255 + fn a_login_lookup_without_the_field_defaults_to_usd() {
256 + let u: UserInfo = serde_json::from_str(
257 + r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":null,
258 + "can_create_projects":true,"suspended":false,"actor_token":"t"}"#,
259 + )
260 + .unwrap();
261 + assert_eq!(u.settlement_currency, Currency::Usd);
262 + }
263 +
264 + #[test]
265 + fn top_project_revenue_reads_the_same_contract() {
266 + let p: ProjectRevenue = serde_json::from_str(
267 + r#"{"id":"p1","title":"T","revenue_cents":5000,"currency":"nzd",
268 + "revenue_cents_by_currency":{"nzd":5000}}"#,
269 + )
270 + .unwrap();
271 + assert_eq!(p.revenue().display(Currency::Usd), "NZ$50.00");
272 + }
273 + }
@@ -1,0 +1,184 @@
1 + //! Projects: the creator's top-level containers, their item lists, and the
2 + //! storage and revenue totals the dashboard reads.
3 +
4 + use super::{MnwApiClient, json_response, revenue_of};
5 + use crate::currency::{Currency, RevenueByCurrency};
6 + use serde::{Deserialize, Serialize};
7 + use std::collections::BTreeMap;
8 +
9 + /// A creator's project with item count and revenue.
10 + #[derive(Debug, Clone, Deserialize, Serialize)]
11 + #[allow(
12 + clippy::struct_field_names,
13 + reason = "project_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
14 + )]
15 + pub(crate) struct Project {
16 + pub id: String,
17 + pub slug: String,
18 + pub title: String,
19 + pub project_type: String,
20 + pub is_public: bool,
21 + pub item_count: i64,
22 + /// Revenue in `currency` — the project's largest single-currency total, not
23 + /// a sum across currencies. Only meaningful next to `currency`.
24 + pub revenue_cents: i64,
25 + /// The currency `revenue_cents` is denominated in. A project can earn in a
26 + /// currency that is not the viewer's: revenue splits are paid in the
27 + /// currency of the project that generated them.
28 + #[serde(default)]
29 + pub currency: Currency,
30 + /// Every currency this project earned in, keyed by lowercase ISO code.
31 + /// Normally one entry matching `revenue_cents`; empty from a server that
32 + /// predates the field.
33 + #[serde(default)]
34 + pub revenue_cents_by_currency: BTreeMap<String, i64>,
35 + }
36 +
37 + impl Project {
38 + /// Revenue across every currency it was earned in.
39 + pub(crate) fn revenue(&self) -> RevenueByCurrency {
40 + revenue_of(
41 + self.revenue_cents,
42 + self.currency,
43 + &self.revenue_cents_by_currency,
44 + )
45 + }
46 + }
47 +
48 + /// An item within a project.
49 + #[derive(Debug, Clone, Deserialize, Serialize)]
50 + #[allow(
51 + clippy::struct_field_names,
52 + reason = "item_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract"
53 + )]
54 + pub(crate) struct Item {
55 + pub id: String,
56 + pub title: String,
57 + pub item_type: String,
58 + pub price_cents: i32,
59 + pub is_public: bool,
60 + pub sort_order: i32,
61 + }
62 +
63 + /// Period comparison stats for the creator.
64 + #[derive(Debug, Clone, Deserialize, Serialize)]
65 + pub(crate) struct CreatorStats {
66 + pub current_revenue_cents: i64,
67 + pub previous_revenue_cents: i64,
68 + pub current_sales: i64,
69 + pub previous_sales: i64,
70 + pub current_followers: i64,
71 + pub previous_followers: i64,
72 + pub total_projects: i64,
73 + pub total_items: i64,
74 + }
75 +
76 + /// Response from the storage-info internal endpoint.
77 + #[derive(Debug, Clone, Deserialize, Serialize)]
78 + pub(crate) struct StorageInfo {
79 + pub storage_used_bytes: i64,
80 + pub max_storage_bytes: i64,
81 + pub allows_file_uploads: bool,
82 + }
83 +
84 + impl MnwApiClient {
85 + /// Fetch all projects for a creator with item counts and revenue.
86 + pub(crate) async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> {
87 + let url = format!("{}/api/internal/creator/projects", self.base_url);
88 + let resp = self
89 + .http
90 + .get(&url)
91 + .bearer_auth(&self.service_token)
92 + .header("X-MNW-Actor", self.actor_header())
93 + .query(&[("user_id", user_id)])
94 + .send()
95 + .await?;
96 +
97 + json_response(resp, "get_projects").await
98 + }
99 +
100 + /// Create a new project.
101 + pub(crate) async fn create_project(
102 + &self,
103 + user_id: &str,
104 + title: &str,
105 + project_type: &str,
106 + description: Option<&str>,
107 + ) -> anyhow::Result<Project> {
108 + let url = format!("{}/api/internal/creator/projects", self.base_url);
109 + let mut body = serde_json::json!({
110 + "user_id": user_id,
111 + "title": title,
112 + "project_type": project_type,
113 + });
114 + if let Some(desc) = description {
115 + body["description"] = serde_json::Value::String(desc.to_string());
116 + }
117 + let resp = self
118 + .http
119 + .post(&url)
120 + .bearer_auth(&self.service_token)
121 + .header("X-MNW-Actor", self.actor_header())
122 + .json(&body)
123 + .send()
124 + .await?;
125 +
126 + json_response(resp, "create_project").await
127 + }
128 +
129 + /// Fetch items in a project.
130 + pub(crate) async fn get_project_items(
131 + &self,
132 + project_id: &str,
133 + user_id: &str,
134 + ) -> anyhow::Result<Vec<Item>> {
135 + let url = format!(
136 + "{}/api/internal/creator/projects/{}/items",
137 + self.base_url, project_id
138 + );
139 + let resp = self
140 + .http
141 + .get(&url)
142 + .bearer_auth(&self.service_token)
143 + .header("X-MNW-Actor", self.actor_header())
144 + .query(&[("user_id", user_id)])
145 + .send()
146 + .await?;
147 +
148 + json_response(resp, "get_project_items").await
149 + }
150 +
151 + /// Fetch period comparison stats for a creator.
152 + pub(crate) async fn get_stats(
153 + &self,
154 + user_id: &str,
155 + range: &str,
156 + ) -> anyhow::Result<CreatorStats> {
157 + let url = format!("{}/api/internal/creator/stats", self.base_url);
158 + let resp = self
159 + .http
160 + .get(&url)
161 + .bearer_auth(&self.service_token)
162 + .header("X-MNW-Actor", self.actor_header())
163 + .query(&[("user_id", user_id), ("range", range)])
164 + .send()
165 + .await?;
166 +
167 + json_response(resp, "get_stats").await
168 + }
169 +
170 + /// Fetch storage usage and limits for a creator.
171 + pub(crate) async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> {
172 + let url = format!("{}/api/internal/creator/storage", self.base_url);
173 + let resp = self
174 + .http
175 + .get(&url)
176 + .bearer_auth(&self.service_token)
177 + .header("X-MNW-Actor", self.actor_header())
178 + .query(&[("user_id", user_id)])
179 + .send()
180 + .await?;
181 +
182 + json_response(resp, "get_storage_info").await
183 + }
184 + }
@@ -1,0 +1,383 @@
1 + //! The storefront around the files: blog posts, promo codes, license keys,
2 + //! subscription tiers, collections, and the broadcast that tells buyers about
3 + //! any of it.
4 +
5 + use super::{MnwApiClient, empty_response, json_response};
6 + use serde::{Deserialize, Serialize};
7 +
8 + /// A blog post summary.
9 + #[derive(Debug, Clone, Deserialize, Serialize)]
10 + pub(crate) struct BlogPost {
11 + pub id: String,
12 + pub title: String,
13 + pub slug: String,
14 + pub is_published: bool,
15 + pub publish_at: Option<String>,
16 + pub created_at: String,
17 + pub updated_at: String,
18 + }
19 +
20 + /// A promo code.
21 + #[derive(Debug, Clone, Deserialize, Serialize)]
22 + pub(crate) struct PromoCode {
23 + pub id: String,
24 + pub code: String,
25 + pub code_purpose: String,
26 + pub discount_type: Option<String>,
27 + pub discount_value: Option<i32>,
28 + pub item_title: Option<String>,
29 + pub project_title: Option<String>,
30 + pub max_uses: Option<i32>,
31 + pub use_count: i32,
32 + pub created_at: String,
33 + }
34 +
35 + /// A license key.
36 + #[derive(Debug, Clone, Deserialize, Serialize)]
37 + pub(crate) struct LicenseKey {
38 + pub id: String,
39 + pub key_code: String,
40 + pub activation_count: i32,
41 + pub max_activations: Option<i32>,
42 + pub is_revoked: bool,
43 + pub created_at: String,
44 + }
45 +
46 + /// Result of a broadcast send.
47 + #[derive(Debug, Deserialize)]
48 + #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not
49 + pub(crate) struct BroadcastResult {
50 + pub success: bool,
51 + pub recipient_count: usize,
52 + }
53 +
54 + /// A subscription tier.
55 + #[derive(Debug, Clone, Deserialize, Serialize)]
56 + pub(crate) struct TierInfo {
57 + pub id: String,
58 + pub name: String,
59 + pub description: String,
60 + pub price_cents: i32,
61 + pub is_active: bool,
62 + }
63 +
64 + /// A collection.
65 + #[derive(Debug, Clone, Deserialize, Serialize)]
66 + pub(crate) struct CollectionInfo {
67 + pub id: String,
68 + pub slug: String,
69 + pub title: String,
70 + pub description: String,
71 + pub is_public: bool,
72 + pub item_count: i64,
73 + }
74 +
75 + impl MnwApiClient {
76 + /// List blog posts for a project.
77 + pub(crate) async fn list_blog_posts(
78 + &self,
79 + user_id: &str,
80 + project_id: &str,
81 + ) -> anyhow::Result<Vec<BlogPost>> {
82 + let url = format!(
83 + "{}/api/internal/creator/projects/{}/blog",
84 + self.base_url, project_id
85 + );
86 + let resp = self
87 + .http
88 + .get(&url)
89 + .bearer_auth(&self.service_token)
90 + .header("X-MNW-Actor", self.actor_header())
91 + .query(&[("user_id", user_id)])
92 + .send()
93 + .await?;
94 +
95 + json_response(resp, "list_blog_posts").await
96 + }
97 +
98 + /// Create a blog post, optionally scheduled for future publication.
99 + pub(crate) async fn create_blog_post(
100 + &self,
101 + user_id: &str,
102 + project_id: &str,
103 + title: &str,
104 + body_markdown: &str,
105 + publish: bool,
106 + publish_at: Option<&str>,
107 + ) -> anyhow::Result<BlogPost> {
108 + let url = format!("{}/api/internal/creator/blog", self.base_url);
109 + let mut body = serde_json::json!({
110 + "user_id": user_id,
111 + "project_id": project_id,
112 + "title": title,
113 + "body_markdown": body_markdown,
114 + "publish": publish,
115 + });
116 + if let Some(pa) = publish_at {
117 + body["publish_at"] = serde_json::Value::String(pa.to_string());
118 + }
119 + let resp = self
120 + .http
121 + .post(&url)
122 + .bearer_auth(&self.service_token)
123 + .header("X-MNW-Actor", self.actor_header())
124 + .json(&body)
125 + .send()
126 + .await?;
127 +
128 + json_response(resp, "create_blog_post").await
129 + }
130 +
131 + /// Delete a blog post.
132 + pub(crate) async fn delete_blog_post(
133 + &self,
134 + user_id: &str,
135 + post_id: &str,
136 + ) -> anyhow::Result<()> {
137 + let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id);
138 + let resp = self
139 + .http
140 + .delete(&url)
141 + .bearer_auth(&self.service_token)
142 + .header("X-MNW-Actor", self.actor_header())
143 + .query(&[("user_id", user_id)])
144 + .send()
145 + .await?;
146 +
147 + empty_response(resp, "delete_blog_post").await
148 + }
149 +
150 + /// List promo codes for a creator.
151 + pub(crate) async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result<Vec<PromoCode>> {
152 + let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
153 + let resp = self
154 + .http
155 + .get(&url)
156 + .bearer_auth(&self.service_token)
157 + .header("X-MNW-Actor", self.actor_header())
158 + .query(&[("user_id", user_id)])
159 + .send()
160 + .await?;
161 +
162 + json_response(resp, "list_promo_codes").await
163 + }
164 +
165 + /// Create a promo code.
166 + pub(crate) async fn create_promo_code(
167 + &self,
168 + user_id: &str,
169 + code: &str,
170 + discount_type: &str,
171 + discount_value: i32,
172 + max_uses: Option<i32>,
173 + project_id: Option<&str>,
174 + ) -> anyhow::Result<PromoCode> {
175 + let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
176 + let mut body = serde_json::json!({
177 + "user_id": user_id,
178 + "code": code,
179 + "code_purpose": "discount",
180 + "discount_type": discount_type,
181 + "discount_value": discount_value,
182 + });
183 + if let Some(max) = max_uses {
184 + body["max_uses"] = serde_json::json!(max);
185 + }
186 + if let Some(pid) = project_id {
187 + body["project_id"] = serde_json::json!(pid);
188 + }
189 +
190 + let resp = self
191 + .http
192 + .post(&url)
193 + .bearer_auth(&self.service_token)
194 + .header("X-MNW-Actor", self.actor_header())
195 + .json(&body)
196 + .send()
197 + .await?;
198 +
199 + json_response(resp, "create_promo_code").await
200 + }
201 +
202 + /// Delete a promo code.
203 + pub(crate) async fn delete_promo_code(
204 + &self,
205 + user_id: &str,
206 + code_id: &str,
207 + ) -> anyhow::Result<()> {
208 + let url = format!(
209 + "{}/api/internal/creator/promo-codes/{}",
210 + self.base_url, code_id
211 + );
212 + let resp = self
213 + .http
214 + .delete(&url)
215 + .bearer_auth(&self.service_token)
216 + .header("X-MNW-Actor", self.actor_header())
217 + .query(&[("user_id", user_id)])
218 + .send()
219 + .await?;
220 +
221 + empty_response(resp, "delete_promo_code").await
222 + }
223 +
224 + /// List license keys for an item.
225 + pub(crate) async fn list_license_keys(
226 + &self,
227 + user_id: &str,
228 + item_id: &str,
229 + ) -> anyhow::Result<Vec<LicenseKey>> {
230 + let url = format!(
231 + "{}/api/internal/creator/items/{}/keys",
232 + self.base_url, item_id
233 + );
234 + let resp = self
235 + .http
236 + .get(&url)
237 + .bearer_auth(&self.service_token)
238 + .header("X-MNW-Actor", self.actor_header())
239 + .query(&[("user_id", user_id)])
240 + .send()
241 + .await?;
242 +
243 + json_response(resp, "list_license_keys").await
244 + }
245 +
246 + /// Generate a new license key for an item.
247 + pub(crate) async fn generate_license_key(
248 + &self,
249 + user_id: &str,
250 + item_id: &str,
251 + ) -> anyhow::Result<LicenseKey> {
252 + let url = format!(
253 + "{}/api/internal/creator/items/{}/keys",
254 + self.base_url, item_id
255 + );
256 + let resp = self
257 + .http
258 + .post(&url)
259 + .bearer_auth(&self.service_token)
260 + .header("X-MNW-Actor", self.actor_header())
261 + .json(&serde_json::json!({ "user_id": user_id }))
262 + .send()
263 + .await?;
264 +
265 + json_response(resp, "generate_license_key").await
266 + }
267 +
268 + /// Revoke a license key.
269 + pub(crate) async fn revoke_license_key(
270 + &self,
271 + user_id: &str,
272 + key_id: &str,
273 + ) -> anyhow::Result<()> {
274 + let url = format!(
275 + "{}/api/internal/creator/keys/{}/revoke",
276 + self.base_url, key_id
277 + );
278 + let resp = self
279 + .http
280 + .post(&url)
281 + .bearer_auth(&self.service_token)
282 + .header("X-MNW-Actor", self.actor_header())
283 + .json(&serde_json::json!({ "user_id": user_id }))
284 + .send()
285 + .await?;
286 +
287 + empty_response(resp, "revoke_license_key").await
288 + }
289 +
290 + pub(crate) async fn send_broadcast(
291 + &self,
292 + user_id: &str,
293 + subject: &str,
294 + body: &str,
295 + ) -> anyhow::Result<BroadcastResult> {
296 + let url = format!("{}/api/internal/creator/broadcast", self.base_url);
297 + let resp = self
298 + .http
299 + .post(&url)
300 + .bearer_auth(&self.service_token)
301 + .header("X-MNW-Actor", self.actor_header())
302 + .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
303 + .send()
304 + .await?;
305 + json_response(resp, "send_broadcast").await
306 + }
307 +
308 + pub(crate) async fn list_tiers(
309 + &self,
310 + user_id: &str,
311 + project_id: &str,
312 + ) -> anyhow::Result<Vec<TierInfo>> {
313 + let url = format!(
314 + "{}/api/internal/creator/projects/{}/tiers",
315 + self.base_url, project_id
316 + );
317 + let resp = self
318 + .http
319 + .get(&url)
320 + .bearer_auth(&self.service_token)
321 + .header("X-MNW-Actor", self.actor_header())
322 + .query(&[("user_id", user_id)])
323 + .send()
324 + .await?;
325 + json_response(resp, "list_tiers").await
326 + }
327 +
328 + pub(crate) async fn list_collections(
329 + &self,
330 + user_id: &str,
331 + ) -> anyhow::Result<Vec<CollectionInfo>> {
332 + let url = format!("{}/api/internal/creator/collections", self.base_url);
333 + let resp = self
334 + .http
335 + .get(&url)
336 + .bearer_auth(&self.service_token)
337 + .header("X-MNW-Actor", self.actor_header())
338 + .query(&[("user_id", user_id)])
339 + .send()
340 + .await?;
341 + json_response(resp, "list_collections").await
342 + }
343 +
344 + #[allow(dead_code)]
345 + pub(crate) async fn create_collection(
346 + &self,
347 + user_id: &str,
348 + slug: &str,
349 + title: &str,
350 + ) -> anyhow::Result<serde_json::Value> {
351 + let url = format!("{}/api/internal/creator/collections", self.base_url);
352 + let resp = self
353 + .http
354 + .post(&url)
355 + .bearer_auth(&self.service_token)
356 + .header("X-MNW-Actor", self.actor_header())
357 + .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
358 + .send()
359 + .await?;
360 + json_response(resp, "create_collection").await
361 + }
362 +
363 + #[allow(dead_code)]
364 + pub(crate) async fn delete_collection(
365 + &self,
366 + user_id: &str,
367 + collection_id: &str,
368 + ) -> anyhow::Result<()> {
369 + let url = format!(
370 + "{}/api/internal/creator/collections/{}",
371 + self.base_url, collection_id
372 + );
373 + let resp = self
374 + .http
375 + .delete(&url)
376 + .bearer_auth(&self.service_token)
377 + .header("X-MNW-Actor", self.actor_header())
378 + .query(&[("user_id", user_id)])
379 + .send()
380 + .await?;
381 + empty_response(resp, "delete_collection").await
382 + }
383 + }
@@ -1,0 +1,422 @@
1 + //! Getting bytes to S3.
2 + //!
3 + //! The one family here with real logic rather than transport boilerplate: a
4 + //! file over [`MULTIPART_THRESHOLD_BYTES`] goes up in parts, which means a part
5 + //! geometry from the server, a bounded set of presigned part URLs refreshed on
6 + //! a [`PART_URL_WINDOW`] cadence, and a completion call that names every ETag.
7 +
8 + use super::{MnwApiClient, json_response};
9 + use serde::Deserialize;
10 +
11 + /// Response from the presign-upload internal endpoint.
12 + #[derive(Debug, Deserialize)]
13 + #[allow(dead_code)]
14 + pub(crate) struct PresignResponse {
15 + pub upload_url: String,
16 + pub s3_key: String,
17 + pub expires_in: u64,
18 + pub cache_control: Option<String>,
19 + }
20 +
21 + /// Above this size an upload goes through a multipart session instead of one
22 + /// presigned PUT. The single-PUT path reads the whole file into memory, which is
23 + /// fine for a small file and unacceptable for a multi-GB one; the multipart path
24 + /// holds one part at a time. It is also the only path that can carry a file past
25 + /// S3's 5 GiB single-PUT ceiling, which is what the tier limits allow for.
26 + pub(crate) const MULTIPART_THRESHOLD_BYTES: u64 = 64 * 1024 * 1024;
27 +
28 + /// How many presigned part URLs to request at a time. Must not exceed the
29 + /// server's own window cap.
30 + const PART_URL_WINDOW: u32 = 100;
31 +
32 + /// An opened multipart upload session.
33 + #[derive(Debug, Clone, Deserialize)]
34 + pub(crate) struct MultipartStart {
35 + pub upload_id: String,
36 + pub s3_key: String,
37 + pub part_size: u64,
38 + pub part_count: u32,
39 + pub expires_in: u64,
40 + }
41 +
42 + /// One presigned part target, with the exact length the signature binds.
43 + #[derive(Debug, Clone, Deserialize)]
44 + pub(crate) struct MultipartPartUrl {
45 + pub part_number: i32,
46 + pub content_length: u64,
47 + pub url: String,
48 + }
49 +
50 + #[derive(Debug, Deserialize)]
51 + struct MultipartPartsResponse {
52 + parts: Vec<MultipartPartUrl>,
53 + }
54 +
55 + impl MnwApiClient {
56 + /// Get a presigned S3 upload URL.
57 + pub(crate) async fn presign_upload(
58 + &self,
59 + user_id: &str,
60 + item_id: &str,
61 + file_type: &str,
62 + file_name: &str,
63 + content_type: &str,
64 + ) -> anyhow::Result<PresignResponse> {
65 + let url = format!("{}/api/internal/upload/presign", self.base_url);
66 + let resp = self
67 + .http
68 + .post(&url)
69 + .bearer_auth(&self.service_token)
70 + .header("X-MNW-Actor", self.actor_header())
71 + .json(&serde_json::json!({
72 + "user_id": user_id,
73 + "item_id": item_id,
74 + "file_type": file_type,
75 + "file_name": file_name,
76 + "content_type": content_type,
77 + }))
78 + .send()
79 + .await?;
80 +
81 + json_response(resp, "presign_upload").await
82 + }
83 +
84 + /// Confirm a completed S3 upload.
85 + pub(crate) async fn confirm_upload(
86 + &self,
87 + user_id: &str,
88 + item_id: &str,
89 + file_type: &str,
90 + s3_key: &str,
91 + ) -> anyhow::Result<bool> {
92 + let url = format!("{}/api/internal/upload/confirm", self.base_url);
93 + let resp = self
94 + .http
95 + .post(&url)
96 + .bearer_auth(&self.service_token)
97 + .header("X-MNW-Actor", self.actor_header())
98 + .json(&serde_json::json!({
99 + "user_id": user_id,
100 + "item_id": item_id,
101 + "file_type": file_type,
102 + "s3_key": s3_key,
103 + }))
104 + .send()
105 + .await?;
106 +
107 + #[derive(Deserialize)]
108 + struct Resp {
109 + success: bool,
110 + }
111 + let r: Resp = json_response(resp, "confirm_upload").await?;
112 + Ok(r.success)
113 + }
114 +
115 + /// Open a multipart upload session and get the part geometry.
116 + pub(crate) async fn multipart_start(
117 + &self,
118 + item_id: &str,
119 + file_type: &str,
120 + file_name: &str,
121 + content_type: &str,
122 + file_size_bytes: u64,
123 + ) -> anyhow::Result<MultipartStart> {
124 + let url = format!("{}/api/internal/upload/multipart/start", self.base_url);
125 + let resp = self
126 + .http
127 + .post(&url)
128 + .bearer_auth(&self.service_token)
129 + .header("X-MNW-Actor", self.actor_header())
130 + .json(&serde_json::json!({
131 + "item_id": item_id,
132 + "file_type": file_type,
133 + "file_name": file_name,
134 + "content_type": content_type,
135 + "file_size_bytes": file_size_bytes,
136 + }))
137 + .send()
138 + .await?;
139 +
140 + json_response(resp, "multipart_start").await
141 + }
142 +
143 + /// Fetch a bounded window of presigned part URLs.
144 + async fn multipart_parts(
145 + &self,
146 + s3_key: &str,
147 + upload_id: &str,
148 + file_size_bytes: u64,
149 + first_part: u32,
150 + count: u32,
151 + ) -> anyhow::Result<Vec<MultipartPartUrl>> {
152 + let url = format!("{}/api/internal/upload/multipart/parts", self.base_url);
153 + let resp = self
154 + .http
155 + .post(&url)
156 + .bearer_auth(&self.service_token)
157 + .header("X-MNW-Actor", self.actor_header())
158 + .json(&serde_json::json!({
159 + "s3_key": s3_key,
160 + "upload_id": upload_id,
161 + "file_size_bytes": file_size_bytes,
162 + "first_part": first_part,
163 + "count": count,
164 + }))
165 + .send()
166 + .await?;
167 +
168 + let parts: MultipartPartsResponse = json_response(resp, "multipart_parts").await?;
169 + Ok(parts.parts)
170 + }
171 +
172 + /// Assemble the uploaded parts into the staging object.
173 + async fn multipart_complete(
174 + &self,
175 + s3_key: &str,
176 + upload_id: &str,
177 + parts: &[(i32, String)],
178 + ) -> anyhow::Result<()> {
179 + let url = format!("{}/api/internal/upload/multipart/complete", self.base_url);
180 + let parts: Vec<serde_json::Value> = parts
181 + .iter()
182 + .map(|(n, etag)| serde_json::json!({ "part_number": n, "etag": etag }))
183 + .collect();
184 + let resp = self
185 + .http
186 + .post(&url)
187 + .bearer_auth(&self.service_token)
188 + .header("X-MNW-Actor", self.actor_header())
189 + .json(&serde_json::json!({
190 + "s3_key": s3_key,
191 + "upload_id": upload_id,
192 + "parts": parts,
193 + }))
194 + .send()
195 + .await?;
196 +
197 + if !resp.status().is_success() {
198 + anyhow::bail!(
199 + "multipart_complete failed: HTTP {} {}",
200 + resp.status(),
201 + resp.text().await.unwrap_or_default()
202 + );
203 + }
204 + Ok(())
205 + }
206 +
207 + /// Release the parts of an abandoned session. Incomplete multipart uploads
208 + /// bill for their parts until aborted.
209 + pub(crate) async fn multipart_abort(
210 + &self,
211 + s3_key: &str,
212 + upload_id: &str,
213 + ) -> anyhow::Result<()> {
214 + let url = format!("{}/api/internal/upload/multipart/abort", self.base_url);
215 + let resp = self
216 + .http
217 + .post(&url)
218 + .bearer_auth(&self.service_token)
219 + .header("X-MNW-Actor", self.actor_header())
220 + .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id }))
221 + .send()
222 + .await?;
223 +
224 + if !resp.status().is_success() {
225 + anyhow::bail!("multipart_abort failed: HTTP {}", resp.status());
226 + }
227 + Ok(())
228 + }
229 +
230 + /// Upload a file through a multipart session, holding one part in memory at
231 + /// a time. Returns the staging key to confirm against.
232 + ///
233 + /// `on_progress` is called with `(bytes_uploaded, total)` after each part.
234 + /// Any failure past the session opening aborts it, so a half-finished upload
235 + /// does not leave parts billing indefinitely — the single abort site means a
236 + /// future failure path added inside cannot forget to.
237 + #[allow(clippy::too_many_arguments)]
238 + pub(crate) async fn upload_file_multipart(
239 + &self,
240 + item_id: &str,
241 + file_type: &str,
242 + file_name: &str,
243 + content_type: &str,
244 + file_path: &std::path::Path,
245 + file_size: u64,
246 + mut on_progress: impl FnMut(u64, u64),
247 + ) -> anyhow::Result<String> {
248 + let start = self
249 + .multipart_start(item_id, file_type, file_name, content_type, file_size)
250 + .await?;
251 +
252 + tracing::info!(
253 + s3_key = %start.s3_key,
254 + part_count = start.part_count,
255 + part_size = start.part_size,
256 + expires_in = start.expires_in,
257 + file_size,
258 + "multipart upload session opened"
259 + );
260 +
261 + match self
262 + .run_multipart_upload(&start, file_path, file_size, &mut on_progress)
263 + .await
264 + {
265 + Ok(()) => Ok(start.s3_key),
266 + Err(e) => {
267 + if let Err(abort_err) = self.multipart_abort(&start.s3_key, &start.upload_id).await
268 + {
269 + tracing::warn!(
270 + error = %abort_err, s3_key = %start.s3_key,
271 + "failed to abort multipart upload after a failed transfer"
272 + );
273 + }
274 + Err(e)
275 + }
276 + }
277 + }
278 +
279 + /// Read and upload every part, then complete. Returns `Err` without
280 + /// aborting; the caller owns the single abort.
281 + async fn run_multipart_upload(
282 + &self,
283 + start: &MultipartStart,
284 + file_path: &std::path::Path,
285 + file_size: u64,
286 + on_progress: &mut impl FnMut(u64, u64),
287 + ) -> anyhow::Result<()> {
288 + use tokio::io::AsyncReadExt;
289 +
290 + let mut file = tokio::fs::File::open(file_path)
291 + .await
292 + .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?;
293 +
294 + let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize);
295 + let mut uploaded: u64 = 0;
296 + let mut next: u32 = 1;
297 +
298 + while next <= start.part_count {
299 + let count = PART_URL_WINDOW.min(start.part_count - next + 1);
300 + let urls = self
301 + .multipart_parts(&start.s3_key, &start.upload_id, file_size, next, count)
302 + .await?;
303 + if urls.is_empty() {
304 + anyhow::bail!("server returned no part URLs for part {next}");
305 + }
306 +
307 + for part in urls {
308 + // One part resident at a time — this is the whole point of the
309 + // multipart path over the single-PUT one.
310 + let mut buf = vec![0u8; part.content_length as usize];
311 + file.read_exact(&mut buf).await.map_err(|e| {
312 + anyhow::anyhow!(
313 + "reading part {} ({} bytes) from {}: {e}",
314 + part.part_number,
315 + part.content_length,
316 + file_path.display()
317 + )
318 + })?;
319 +
320 + let etag = self.put_part(&part, buf).await?;
321 + completed.push((part.part_number, etag));
322 + uploaded += part.content_length;
323 + on_progress(uploaded, file_size);
324 + next += 1;
325 + }
326 + }
327 +
328 + self.multipart_complete(&start.s3_key, &start.upload_id, &completed)
329 + .await
330 + }
331 +
332 + /// PUT one part to its presigned URL, returning the ETag the completion call
333 + /// needs. Retries transient failures — losing a part to a network blip
334 + /// should not discard the whole transfer.
335 + async fn put_part(&self, part: &MultipartPartUrl, body: Vec<u8>) -> anyhow::Result<String> {
336 + // `Bytes` so a retry clones a refcount rather than re-copying the part.
337 + let body = bytes::Bytes::from(body);
338 + let mut attempt: u32 = 0;
339 + loop {
340 + attempt += 1;
341 + let sent = self
342 + .http
343 + .put(&part.url)
344 + .header(reqwest::header::CONTENT_LENGTH, part.content_length)
345 + .body(body.clone())
346 + .send()
347 + .await;
348 +
349 + let retriable = match sent {
350 + Ok(resp) if resp.status().is_success() => {
351 + let etag = resp
352 + .headers()
353 + .get(reqwest::header::ETAG)
354 + .and_then(|v| v.to_str().ok())
355 + .unwrap_or_default()
356 + .to_string();
357 + if etag.is_empty() {
358 + anyhow::bail!(
359 + "S3 returned no ETag for part {}; cannot complete the upload",
360 + part.part_number
361 + );
362 + }
363 + return Ok(etag);
364 + }
365 + Ok(resp) => {
366 + let status = resp.status();
367 + if attempt >= 3 {
368 + anyhow::bail!(
369 + "part {} failed after {attempt} attempts: HTTP {status}",
370 + part.part_number
371 + );
372 + }
373 + format!("HTTP {status}")
374 + }
375 + Err(e) => {
376 + if attempt >= 3 {
377 + return Err(anyhow::Error::new(e).context(format!(
378 + "part {} failed after {attempt} attempts",
379 + part.part_number
380 + )));
381 + }
382 + e.to_string()
383 + }
384 + };
385 +
386 + let delay_ms = 200u64 * (1u64 << ((attempt - 1) * 2));
387 + tracing::warn!(
388 + part_number = part.part_number, attempt, delay_ms, error = %retriable,
389 + "part upload transient failure, retrying"
390 + );
391 + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
392 + }
393 + }
394 +
395 + /// Upload a file to S3 using a presigned URL.
396 + pub(crate) async fn upload_to_s3(
397 + &self,
398 + presigned_url: &str,
399 + file_path: &std::path::Path,
400 + content_type: &str,
401 + cache_control: Option<&str>,
402 + ) -> anyhow::Result<()> {
403 + let data = tokio::fs::read(file_path).await?;
404 + let mut req = self
405 + .http
406 + .put(presigned_url)
407 + .header("content-type", content_type)
408 + .body(data);
409 +
410 + if let Some(cc) = cache_control {
411 + req = req.header("cache-control", cc);
412 + }
413 +
414 + let resp = req.send().await?;
415 +
416 + if !resp.status().is_success() {
417 + anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
418 + }
419 +
420 + Ok(())
421 + }
422 + }