//! HTTP client for the Multithreaded internal API. //! //! Signs requests with HMAC-SHA256 and communicates with MT's `/internal/*` endpoints. use hmac::{Hmac, KeyInit, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use uuid::Uuid; use crate::db::MtThreadId; /// Errors from the MT internal API client. #[derive(Debug, thiserror::Error)] pub enum MtClientError { #[error("MT unreachable: {0}")] Unreachable(reqwest::Error), #[error("MT returned error status {status}: {body}")] BadResponse { status: u16, body: String }, #[error("failed to deserialize MT response: {0}")] Deserialize(reqwest::Error), } /// HTTP client for MT's internal API with HMAC-SHA256 request signing. #[derive(Clone)] pub struct MtClient { http: reqwest::Client, base_url: String, secret: String, } // Request/response types (must match MT's internal API) #[derive(Serialize)] pub struct CreateCommunityRequest { pub name: String, pub slug: String, pub description: Option, pub owner_mnw_id: Uuid, pub owner_username: String, pub owner_display_name: Option, } #[derive(Deserialize)] pub struct CreateCommunityResponse { pub community_id: Uuid, pub created: bool, } #[derive(Serialize)] pub struct CreateThreadRequest { pub community_slug: String, pub category_slug: String, pub title: String, pub body_markdown: String, pub author_mnw_id: Uuid, pub author_username: String, pub author_display_name: Option, pub external_ref: String, } #[derive(Deserialize)] pub struct CreateThreadResponse { pub thread_id: MtThreadId, pub post_id: Uuid, pub created: bool, } #[derive(Serialize)] pub struct CreatePostRequest { pub body_markdown: String, pub author_mnw_id: Uuid, pub author_username: String, pub author_display_name: Option, /// Idempotency key, MT dedups a retried/replayed reply on this. Use a /// stable per-message value (e.g. `mnw:post:`). pub external_ref: String, } #[derive(Deserialize)] pub struct CreatePostResponse { pub post_id: Uuid, /// False when MT returned an existing reply (the ref was already seen). #[serde(default)] pub created: bool, } #[derive(Deserialize)] pub struct ThreadStatsResponse { pub post_count: i64, pub last_activity_at: Option>, } impl MtClient { /// Create a new MT client with the given base URL and shared secret. pub fn new(base_url: String, secret: String) -> Self { // `build()` constructs the rustls connector, which reads the // process-wide provider and panics if none is installed. Idempotent. crate::crypto::install_default_crypto_provider(); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(3)) .build() .expect("failed to build MT HTTP client"); Self { http, base_url, secret, } } /// Sign a request, binding method + path + a fresh nonce in addition to the /// timestamp and body. Returns (timestamp, nonce, hex signature). The /// canonical message, `timestamp \n METHOD \n PATH \n NONCE \n body`, must /// match MT's `compute_internal_signature_v2` byte-for-byte. `path` is the /// request path only (no scheme/host, no query string). fn sign_request(&self, method: &str, path: &str, body: &str) -> (String, String, String) { let timestamp = chrono::Utc::now().timestamp().to_string(); let nonce = Uuid::new_v4().simple().to_string(); let mut mac = Hmac::::new_from_slice(self.secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(timestamp.as_bytes()); mac.update(b"\n"); mac.update(method.as_bytes()); mac.update(b"\n"); mac.update(path.as_bytes()); mac.update(b"\n"); mac.update(nonce.as_bytes()); mac.update(b"\n"); mac.update(body.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); (timestamp, nonce, signature) } /// Send a signed POST request and deserialize the response. async fn signed_post Deserialize<'de>>( &self, path: &str, req: &Req, ) -> Result { let body = serde_json::to_string(req).expect("request serialization cannot fail"); let (timestamp, nonce, signature) = self.sign_request("POST", path, &body); let resp = self .http .post(format!("{}{}", self.base_url, path)) .header("Content-Type", "application/json") .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .header("X-Internal-Nonce", &nonce) .body(body) .send() .await .map_err(MtClientError::Unreachable)?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(MtClientError::BadResponse { status: status.as_u16(), body, }); } resp.json().await.map_err(MtClientError::Deserialize) } /// Create or retrieve an existing community on MT. #[tracing::instrument(skip_all)] pub async fn create_community( &self, req: &CreateCommunityRequest, ) -> Result { self.signed_post("/internal/communities", req).await } /// Create a discussion thread on MT linked to MNW content. #[tracing::instrument(skip_all)] pub async fn create_thread( &self, req: &CreateThreadRequest, ) -> Result { self.signed_post("/internal/threads", req).await } /// Add a reply to an existing thread on MT. #[tracing::instrument(skip_all, fields(thread_id = %thread_id))] pub async fn create_post( &self, thread_id: MtThreadId, req: &CreatePostRequest, ) -> Result { self.signed_post(&format!("/internal/threads/{thread_id}/posts"), req) .await } /// Get thread stats (post count + last activity). #[tracing::instrument(skip_all, fields(thread_id = %thread_id))] pub async fn get_thread_stats( &self, thread_id: MtThreadId, ) -> Result { let path = format!("/internal/threads/{thread_id}/stats"); let (timestamp, nonce, signature) = self.sign_request("GET", &path, ""); let resp = self .http .get(format!("{}{}", self.base_url, path)) .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .header("X-Internal-Nonce", &nonce) .send() .await .map_err(MtClientError::Unreachable)?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(MtClientError::BadResponse { status: status.as_u16(), body, }); } resp.json().await.map_err(MtClientError::Deserialize) } } #[cfg(test)] mod tests { use super::*; #[test] fn sign_request_produces_valid_signature_and_fresh_nonce() { let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string()); let body = r#"{"name":"test"}"#; let (ts1, nonce1, sig1) = client.sign_request("POST", "/internal/communities", body); let (ts2, nonce2, sig2) = client.sign_request("POST", "/internal/communities", body); let t1: i64 = ts1.parse().unwrap(); let t2: i64 = ts2.parse().unwrap(); assert!((t1 - t2).abs() <= 1); assert_eq!(sig1.len(), 64, "SHA-256 hex is 64 chars"); assert!(sig1.chars().all(|c| c.is_ascii_hexdigit())); // Each request carries a fresh nonce, so even identical method/path/body // produce a distinct signature, single-use by construction. assert_ne!(nonce1, nonce2, "nonce must be fresh per request"); assert_ne!(sig1, sig2, "fresh nonce must change the signature"); } /// Recompute the canonical v2 message inline to pin that method, path, and /// nonce are all bound (a mutation dropping any field would collide). #[test] fn signed_message_binds_method_path_nonce() { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; fn sig( secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &str, ) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); for field in [ts, method, path, nonce] { mac.update(field.as_bytes()); mac.update(b"\n"); } mac.update(body.as_bytes()); hex::encode(mac.finalize().into_bytes()) } // The verifier is multithreaded's compute_internal_signature_v2, so the // exact bytes are a cross-repo contract that neither repo can catch by // agreeing with itself. Both ends pin this same independently computed // HMAC-SHA256: key "secret", message "100\nPOST\n/x\nn\nbody". assert_eq!( sig("secret", "100", "POST", "/x", "n", "body"), "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098" ); // Tie the production signer to that layout, so the pinned vector above // constrains sign_request and not just this local reimplementation. let client = MtClient::new("http://localhost".to_string(), "s".to_string()); let (ts, nonce, produced) = client.sign_request("POST", "/a", "body"); assert_eq!(produced, sig("s", &ts, "POST", "/a", &nonce, "body")); let base = sig("s", "100", "POST", "/a", "n1", "body"); assert_ne!( base, sig("s", "100", "GET", "/a", "n1", "body"), "method bound" ); assert_ne!( base, sig("s", "100", "POST", "/b", "n1", "body"), "path bound" ); assert_ne!( base, sig("s", "100", "POST", "/a", "n2", "body"), "nonce bound" ); assert_ne!( base, sig("s", "100", "POST", "/a", "n1", "body2"), "body bound" ); } }