//! HTTP client for the MNW internal API. use serde::{Deserialize, Serialize}; /// User info returned from the SSH key lookup endpoint. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct UserInfo { pub user_id: String, pub username: String, pub display_name: Option, pub creator_tier: Option, pub can_create_projects: bool, pub suspended: bool, /// Signed actor assertion the server mints at lookup; forwarded as /// `X-MNW-Actor` on internal calls so the server derives identity from an /// SSH-authenticated token rather than a caller-supplied `user_id`. #[serde(default)] pub actor_token: String, } /// A creator's project with item count and revenue. #[derive(Debug, Clone, Deserialize, Serialize)] #[allow( clippy::struct_field_names, reason = "project_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" )] pub(crate) struct Project { pub id: String, pub slug: String, pub title: String, pub project_type: String, pub is_public: bool, pub item_count: i64, pub revenue_cents: i64, } /// An item within a project. #[derive(Debug, Clone, Deserialize, Serialize)] #[allow( clippy::struct_field_names, reason = "item_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" )] pub(crate) struct Item { pub id: String, pub title: String, pub item_type: String, pub price_cents: i32, pub is_public: bool, pub sort_order: i32, } /// Period comparison stats for the creator. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct CreatorStats { pub current_revenue_cents: i64, pub previous_revenue_cents: i64, pub current_sales: i64, pub previous_sales: i64, pub current_followers: i64, pub previous_followers: i64, pub total_projects: i64, pub total_items: i64, } /// Response from the create-item internal endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct ItemCreated { pub item_id: String, pub project_id: String, } /// Response from the presign-upload internal endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct PresignResponse { pub upload_url: String, pub s3_key: String, pub expires_in: u64, pub cache_control: Option, } /// Above this size an upload goes through a multipart session instead of one /// presigned PUT. The single-PUT path reads the whole file into memory, which is /// fine for a small file and unacceptable for a multi-GB one; the multipart path /// holds one part at a time. It is also the only path that can carry a file past /// S3's 5 GiB single-PUT ceiling, which is what the tier limits allow for. pub(crate) const MULTIPART_THRESHOLD_BYTES: u64 = 64 * 1024 * 1024; /// How many presigned part URLs to request at a time. Must not exceed the /// server's own window cap. const PART_URL_WINDOW: u32 = 100; /// An opened multipart upload session. #[derive(Debug, Clone, Deserialize)] pub(crate) struct MultipartStart { pub upload_id: String, pub s3_key: String, pub part_size: u64, pub part_count: u32, pub expires_in: u64, } /// One presigned part target, with the exact length the signature binds. #[derive(Debug, Clone, Deserialize)] pub(crate) struct MultipartPartUrl { pub part_number: i32, pub content_length: u64, pub url: String, } #[derive(Debug, Deserialize)] struct MultipartPartsResponse { parts: Vec, } /// Full item detail returned from the get/update endpoints. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct ItemDetail { pub id: String, pub title: String, pub description: Option, pub price_cents: i32, pub item_type: String, pub is_public: bool, pub slug: String, pub sort_order: i32, pub sales_count: i32, pub download_count: i32, pub play_count: i32, pub pwyw_enabled: bool, pub pwyw_min_cents: Option, pub has_audio: bool, pub has_cover: bool, pub created_at: String, pub updated_at: String, } /// A version of an item. #[derive(Debug, Clone, Deserialize, Serialize)] #[allow( clippy::struct_field_names, reason = "version_number mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" )] pub(crate) struct Version { pub id: String, pub version_number: String, pub changelog: Option, pub file_name: Option, pub file_size_bytes: Option, pub download_count: i32, pub is_current: bool, pub created_at: String, } /// A blog post summary. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct BlogPost { pub id: String, pub title: String, pub slug: String, pub is_published: bool, pub publish_at: Option, pub created_at: String, pub updated_at: String, } /// A promo code. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct PromoCode { pub id: String, pub code: String, pub code_purpose: String, pub discount_type: Option, pub discount_value: Option, pub item_title: Option, pub project_title: Option, pub max_uses: Option, pub use_count: i32, pub created_at: String, } /// A license key. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct LicenseKey { pub id: String, pub key_code: String, pub activation_count: i32, pub max_activations: Option, pub is_revoked: bool, pub created_at: String, } /// Response from the storage-info internal endpoint. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct StorageInfo { pub storage_used_bytes: i64, pub max_storage_bytes: i64, pub allows_file_uploads: bool, } /// A revenue bucket for analytics timeseries. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct AnalyticsBucket { pub label: String, pub revenue_cents: i64, pub sales_count: i64, } /// Per-project revenue summary. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct ProjectRevenue { pub id: String, pub title: String, pub revenue_cents: i64, } /// Analytics response with timeseries, comparison, and top projects. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct AnalyticsData { pub buckets: Vec, pub current_revenue_cents: i64, pub previous_revenue_cents: i64, pub current_sales: i64, pub previous_sales: i64, pub current_followers: i64, pub previous_followers: i64, pub top_projects: Vec, } /// A seller transaction. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct Transaction { pub id: String, pub item_title: Option, pub amount_cents: i32, pub status: String, pub created_at: String, pub completed_at: Option, } /// CSV export result. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct ExportResult { pub csv: String, pub row_count: usize, } /// A registered SSH key. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct SshKeyInfo { pub id: String, pub label: String, pub fingerprint: String, pub created_at: String, } /// A tag on an item or from search. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct TagInfo { pub id: String, pub name: String, pub slug: String, pub is_primary: bool, } /// Result of a broadcast send. #[derive(Debug, Deserialize)] #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not pub(crate) struct BroadcastResult { pub success: bool, pub recipient_count: usize, } /// A subscription tier. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct TierInfo { pub id: String, pub name: String, pub description: String, pub price_cents: i32, pub is_active: bool, } /// A collection. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct CollectionInfo { pub id: String, pub slug: String, pub title: String, pub description: String, pub is_public: bool, pub item_count: i64, } /// Custom domain info. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct DomainInfo { pub id: String, pub domain: String, pub verified: bool, pub verification_token: String, pub instructions: Option, } /// Domain verification result. #[derive(Debug, Deserialize)] #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not pub(crate) struct DomainVerifyResult { pub verified: bool, pub message: String, } /// Response from the git authorize endpoint. #[derive(Debug, Deserialize)] pub(crate) struct GitAuthResponse { pub repo_path: String, } /// Check response status and deserialize JSON body, or bail with error details. async fn json_response( resp: reqwest::Response, context: &str, ) -> anyhow::Result { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, %context, "failed to read error response body"); String::new() }); if body.is_empty() { anyhow::bail!("{context} failed: HTTP {status}"); } anyhow::bail!("{context} failed: HTTP {status} — {body}"); } Ok(resp.json().await?) } /// Check response status for success, or bail with error details. async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, %context, "failed to read error response body"); String::new() }); if body.is_empty() { anyhow::bail!("{context} failed: HTTP {status}"); } anyhow::bail!("{context} failed: HTTP {status} — {body}"); } Ok(()) } /// Client for calling MNW internal API endpoints. #[derive(Clone)] pub(crate) struct MnwApiClient { http: reqwest::Client, base_url: String, service_token: String, /// Set once per session from the SSH-key-lookup response; forwarded on /// internal creator calls as `X-MNW-Actor`. actor_token: Option, } impl MnwApiClient { pub(crate) fn new(base_url: String, service_token: String) -> Self { let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .expect("failed to build HTTP client"); Self { http, base_url, service_token, actor_token: None, } } /// Record the actor assertion for the authenticated session. Subsequent /// internal calls forward it so the server can verify the acting identity. pub(crate) fn set_actor_token(&mut self, token: String) { self.actor_token = Some(token); } /// The `X-MNW-Actor` header value for internal calls (empty before lookup). fn actor_header(&self) -> &str { self.actor_token.as_deref().unwrap_or("") } /// Look up a user by SSH key fingerprint. /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. pub(crate) async fn lookup_ssh_key( &self, fingerprint: &str, ) -> anyhow::Result> { let url = format!("{}/api/internal/ssh-key-lookup", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("fingerprint", fingerprint)]) .send() .await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status()); } let info: UserInfo = resp.json().await?; Ok(Some(info)) } /// Fetch all projects for a creator with item counts and revenue. pub(crate) async fn get_projects(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/projects", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_projects").await } /// Create a new project. pub(crate) async fn create_project( &self, user_id: &str, title: &str, project_type: &str, description: Option<&str>, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/projects", self.base_url); let mut body = serde_json::json!({ "user_id": user_id, "title": title, "project_type": project_type, }); if let Some(desc) = description { body["description"] = serde_json::Value::String(desc.to_string()); } let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&body) .send() .await?; json_response(resp, "create_project").await } /// Fetch items in a project. pub(crate) async fn get_project_items( &self, project_id: &str, user_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/projects/{}/items", self.base_url, project_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_project_items").await } /// Fetch period comparison stats for a creator. pub(crate) async fn get_stats( &self, user_id: &str, range: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/stats", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id), ("range", range)]) .send() .await?; json_response(resp, "get_stats").await } /// Fetch storage usage and limits for a creator. pub(crate) async fn get_storage_info(&self, user_id: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/storage", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_storage_info").await } /// Create an item in a project. pub(crate) async fn create_item( &self, user_id: &str, project_id: &str, title: &str, item_type: &str, price_cents: i32, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id, "project_id": project_id, "title": title, "item_type": item_type, "price_cents": price_cents, })) .send() .await?; json_response(resp, "create_item").await } /// Get a presigned S3 upload URL. pub(crate) async fn presign_upload( &self, user_id: &str, item_id: &str, file_type: &str, file_name: &str, content_type: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/upload/presign", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id, "item_id": item_id, "file_type": file_type, "file_name": file_name, "content_type": content_type, })) .send() .await?; json_response(resp, "presign_upload").await } /// Confirm a completed S3 upload. pub(crate) async fn confirm_upload( &self, user_id: &str, item_id: &str, file_type: &str, s3_key: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/upload/confirm", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id, "item_id": item_id, "file_type": file_type, "s3_key": s3_key, })) .send() .await?; #[derive(Deserialize)] struct Resp { success: bool, } let r: Resp = json_response(resp, "confirm_upload").await?; Ok(r.success) } /// Fetch full item detail. pub(crate) async fn get_item_detail( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_item_detail").await } /// Update item fields. Only non-None fields are changed. pub(crate) async fn update_item( &self, user_id: &str, item_id: &str, title: Option<&str>, description: Option<&str>, price_cents: Option, is_public: Option, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let mut body = serde_json::json!({ "user_id": user_id }); if let Some(t) = title { body["title"] = serde_json::Value::String(t.to_string()); } if let Some(d) = description { body["description"] = serde_json::Value::String(d.to_string()); } if let Some(p) = price_cents { body["price_cents"] = serde_json::json!(p); } if let Some(v) = is_public { body["is_public"] = serde_json::json!(v); } let resp = self .http .put(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&body) .send() .await?; json_response(resp, "update_item").await } /// Delete an item permanently. pub(crate) async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_item").await } /// Publish an item (set is_public=true). pub(crate) async fn publish_item( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/publish", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "publish_item").await } /// Unpublish an item (set is_public=false). pub(crate) async fn unpublish_item( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/unpublish", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "unpublish_item").await } /// Fetch versions for an item. pub(crate) async fn get_item_versions( &self, user_id: &str, item_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/items/{}/versions", self.base_url, item_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_item_versions").await } // ── Multipart upload session (large files) ── /// Open a multipart upload session and get the part geometry. pub(crate) async fn multipart_start( &self, item_id: &str, file_type: &str, file_name: &str, content_type: &str, file_size_bytes: u64, ) -> anyhow::Result { let url = format!("{}/api/internal/upload/multipart/start", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "item_id": item_id, "file_type": file_type, "file_name": file_name, "content_type": content_type, "file_size_bytes": file_size_bytes, })) .send() .await?; json_response(resp, "multipart_start").await } /// Fetch a bounded window of presigned part URLs. async fn multipart_parts( &self, s3_key: &str, upload_id: &str, file_size_bytes: u64, first_part: u32, count: u32, ) -> anyhow::Result> { let url = format!("{}/api/internal/upload/multipart/parts", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id, "file_size_bytes": file_size_bytes, "first_part": first_part, "count": count, })) .send() .await?; let parts: MultipartPartsResponse = json_response(resp, "multipart_parts").await?; Ok(parts.parts) } /// Assemble the uploaded parts into the staging object. async fn multipart_complete( &self, s3_key: &str, upload_id: &str, parts: &[(i32, String)], ) -> anyhow::Result<()> { let url = format!("{}/api/internal/upload/multipart/complete", self.base_url); let parts: Vec = parts .iter() .map(|(n, etag)| serde_json::json!({ "part_number": n, "etag": etag })) .collect(); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id, "parts": parts, })) .send() .await?; if !resp.status().is_success() { anyhow::bail!( "multipart_complete failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default() ); } Ok(()) } /// Release the parts of an abandoned session. Incomplete multipart uploads /// bill for their parts until aborted. pub(crate) async fn multipart_abort( &self, s3_key: &str, upload_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/api/internal/upload/multipart/abort", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id })) .send() .await?; if !resp.status().is_success() { anyhow::bail!("multipart_abort failed: HTTP {}", resp.status()); } Ok(()) } /// Upload a file through a multipart session, holding one part in memory at /// a time. Returns the staging key to confirm against. /// /// `on_progress` is called with `(bytes_uploaded, total)` after each part. /// Any failure past the session opening aborts it, so a half-finished upload /// does not leave parts billing indefinitely — the single abort site means a /// future failure path added inside cannot forget to. #[allow(clippy::too_many_arguments)] pub(crate) async fn upload_file_multipart( &self, item_id: &str, file_type: &str, file_name: &str, content_type: &str, file_path: &std::path::Path, file_size: u64, mut on_progress: impl FnMut(u64, u64), ) -> anyhow::Result { let start = self .multipart_start(item_id, file_type, file_name, content_type, file_size) .await?; tracing::info!( s3_key = %start.s3_key, part_count = start.part_count, part_size = start.part_size, expires_in = start.expires_in, file_size, "multipart upload session opened" ); match self .run_multipart_upload(&start, file_path, file_size, &mut on_progress) .await { Ok(()) => Ok(start.s3_key), Err(e) => { if let Err(abort_err) = self.multipart_abort(&start.s3_key, &start.upload_id).await { tracing::warn!( error = %abort_err, s3_key = %start.s3_key, "failed to abort multipart upload after a failed transfer" ); } Err(e) } } } /// Read and upload every part, then complete. Returns `Err` without /// aborting; the caller owns the single abort. async fn run_multipart_upload( &self, start: &MultipartStart, file_path: &std::path::Path, file_size: u64, on_progress: &mut impl FnMut(u64, u64), ) -> anyhow::Result<()> { use tokio::io::AsyncReadExt; let mut file = tokio::fs::File::open(file_path) .await .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?; let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize); let mut uploaded: u64 = 0; let mut next: u32 = 1; while next <= start.part_count { let count = PART_URL_WINDOW.min(start.part_count - next + 1); let urls = self .multipart_parts(&start.s3_key, &start.upload_id, file_size, next, count) .await?; if urls.is_empty() { anyhow::bail!("server returned no part URLs for part {next}"); } for part in urls { // One part resident at a time — this is the whole point of the // multipart path over the single-PUT one. let mut buf = vec![0u8; part.content_length as usize]; file.read_exact(&mut buf).await.map_err(|e| { anyhow::anyhow!( "reading part {} ({} bytes) from {}: {e}", part.part_number, part.content_length, file_path.display() ) })?; let etag = self.put_part(&part, buf).await?; completed.push((part.part_number, etag)); uploaded += part.content_length; on_progress(uploaded, file_size); next += 1; } } self.multipart_complete(&start.s3_key, &start.upload_id, &completed) .await } /// PUT one part to its presigned URL, returning the ETag the completion call /// needs. Retries transient failures — losing a part to a network blip /// should not discard the whole transfer. async fn put_part(&self, part: &MultipartPartUrl, body: Vec) -> anyhow::Result { // `Bytes` so a retry clones a refcount rather than re-copying the part. let body = bytes::Bytes::from(body); let mut attempt: u32 = 0; loop { attempt += 1; let sent = self .http .put(&part.url) .header(reqwest::header::CONTENT_LENGTH, part.content_length) .body(body.clone()) .send() .await; let retriable = match sent { Ok(resp) if resp.status().is_success() => { let etag = resp .headers() .get(reqwest::header::ETAG) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_string(); if etag.is_empty() { anyhow::bail!( "S3 returned no ETag for part {}; cannot complete the upload", part.part_number ); } return Ok(etag); } Ok(resp) => { let status = resp.status(); if attempt >= 3 { anyhow::bail!( "part {} failed after {attempt} attempts: HTTP {status}", part.part_number ); } format!("HTTP {status}") } Err(e) => { if attempt >= 3 { return Err(anyhow::Error::new(e).context(format!( "part {} failed after {attempt} attempts", part.part_number ))); } e.to_string() } }; let delay_ms = 200u64 * (1u64 << ((attempt - 1) * 2)); tracing::warn!( part_number = part.part_number, attempt, delay_ms, error = %retriable, "part upload transient failure, retrying" ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; } } /// Upload a file to S3 using a presigned URL. pub(crate) async fn upload_to_s3( &self, presigned_url: &str, file_path: &std::path::Path, content_type: &str, cache_control: Option<&str>, ) -> anyhow::Result<()> { let data = tokio::fs::read(file_path).await?; let mut req = self .http .put(presigned_url) .header("content-type", content_type) .body(data); if let Some(cc) = cache_control { req = req.header("cache-control", cc); } let resp = req.send().await?; if !resp.status().is_success() { anyhow::bail!("S3 upload failed: HTTP {}", resp.status()); } Ok(()) } // ── Blog posts ── /// List blog posts for a project. pub(crate) async fn list_blog_posts( &self, user_id: &str, project_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/projects/{}/blog", self.base_url, project_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_blog_posts").await } /// Create a blog post, optionally scheduled for future publication. pub(crate) async fn create_blog_post( &self, user_id: &str, project_id: &str, title: &str, body_markdown: &str, publish: bool, publish_at: Option<&str>, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/blog", self.base_url); let mut body = serde_json::json!({ "user_id": user_id, "project_id": project_id, "title": title, "body_markdown": body_markdown, "publish": publish, }); if let Some(pa) = publish_at { body["publish_at"] = serde_json::Value::String(pa.to_string()); } let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&body) .send() .await?; json_response(resp, "create_blog_post").await } /// Delete a blog post. pub(crate) async fn delete_blog_post( &self, user_id: &str, post_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_blog_post").await } // ── Promo codes ── /// List promo codes for a creator. pub(crate) async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/promo-codes", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_promo_codes").await } /// Create a promo code. pub(crate) async fn create_promo_code( &self, user_id: &str, code: &str, discount_type: &str, discount_value: i32, max_uses: Option, project_id: Option<&str>, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/promo-codes", self.base_url); let mut body = serde_json::json!({ "user_id": user_id, "code": code, "code_purpose": "discount", "discount_type": discount_type, "discount_value": discount_value, }); if let Some(max) = max_uses { body["max_uses"] = serde_json::json!(max); } if let Some(pid) = project_id { body["project_id"] = serde_json::json!(pid); } let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&body) .send() .await?; json_response(resp, "create_promo_code").await } /// Delete a promo code. pub(crate) async fn delete_promo_code( &self, user_id: &str, code_id: &str, ) -> anyhow::Result<()> { let url = format!( "{}/api/internal/creator/promo-codes/{}", self.base_url, code_id ); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_promo_code").await } // ── License keys ── /// List license keys for an item. pub(crate) async fn list_license_keys( &self, user_id: &str, item_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/items/{}/keys", self.base_url, item_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_license_keys").await } /// Generate a new license key for an item. pub(crate) async fn generate_license_key( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/keys", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "generate_license_key").await } /// Revoke a license key. pub(crate) async fn revoke_license_key( &self, user_id: &str, key_id: &str, ) -> anyhow::Result<()> { let url = format!( "{}/api/internal/creator/keys/{}/revoke", self.base_url, key_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; empty_response(resp, "revoke_license_key").await } // ── Analytics ── /// Get analytics data (timeseries, period comparison, top projects). pub(crate) async fn get_analytics( &self, user_id: &str, range: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/analytics", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id), ("range", range)]) .send() .await?; json_response(resp, "get_analytics").await } /// Get recent seller transactions. pub(crate) async fn get_transactions(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/transactions", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_transactions").await } /// Export sales as CSV string. pub(crate) async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/export/sales", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "export_sales_csv").await } // ── SSH keys ── /// Authorize a git operation and get the on-disk repo path. pub(crate) async fn git_authorize( &self, user_id: &str, operation: &str, owner: &str, repo_name: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/git/authorize", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({ "user_id": user_id, "operation": operation, "owner": owner, "repo_name": repo_name, })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, "failed to read git_authorize error body"); String::new() }); // Parse JSON error if available, fall back to status text let msg = serde_json::from_str::(&body) .ok() .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) .unwrap_or_else(|| format!("HTTP {status}")); anyhow::bail!("{msg}"); } Ok(resp.json().await?) } /// List registered SSH keys for a user. pub(crate) async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/ssh-keys", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_ssh_keys").await } // ── Tags ── pub(crate) async fn list_item_tags( &self, user_id: &str, item_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/items/{}/tags", self.base_url, item_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_item_tags").await } pub(crate) async fn search_tags(&self, query: &str) -> anyhow::Result> { let url = format!("{}/api/internal/tags/search", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("q", query)]) .send() .await?; json_response(resp, "search_tags").await } pub(crate) async fn add_item_tag( &self, user_id: &str, item_id: &str, tag_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/items/tags", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) .send() .await?; empty_response(resp, "add_item_tag").await } // Unused by the TUI today; kept so the client mirrors the full // /api/internal surface rather than only the paths one caller happens to hit. #[allow(dead_code)] pub(crate) async fn remove_item_tag( &self, user_id: &str, item_id: &str, tag_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) .send() .await?; empty_response(resp, "remove_item_tag").await } // ── Broadcast ── pub(crate) async fn send_broadcast( &self, user_id: &str, subject: &str, body: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/broadcast", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body})) .send() .await?; json_response(resp, "send_broadcast").await } // ── Tiers ── pub(crate) async fn list_tiers( &self, user_id: &str, project_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_tiers").await } // ── Collections ── pub(crate) async fn list_collections( &self, user_id: &str, ) -> anyhow::Result> { let url = format!("{}/api/internal/creator/collections", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_collections").await } #[allow(dead_code)] pub(crate) async fn create_collection( &self, user_id: &str, slug: &str, title: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/collections", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title})) .send() .await?; json_response(resp, "create_collection").await } #[allow(dead_code)] pub(crate) async fn delete_collection( &self, user_id: &str, collection_id: &str, ) -> anyhow::Result<()> { let url = format!( "{}/api/internal/creator/collections/{}", self.base_url, collection_id ); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_collection").await } // ── Custom Domains ── pub(crate) async fn get_domain(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/domain", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; let val: serde_json::Value = json_response(resp, "get_domain").await?; if val.is_null() { return Ok(None); } Ok(serde_json::from_value(val).ok()) } pub(crate) async fn add_domain( &self, user_id: &str, domain: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/domain", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .json(&serde_json::json!({"user_id": user_id, "domain": domain})) .send() .await?; json_response(resp, "add_domain").await } pub(crate) async fn verify_domain(&self, user_id: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/domain/verify", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "verify_domain").await } pub(crate) async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/domain", self.base_url); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "remove_domain").await } }