memvid_cli/
api.rs

1//! API client for Memvid control plane
2//!
3//! This module provides HTTP client functionality for interacting with the Memvid
4//! control plane API, specifically for ticket synchronization and application.
5
6use std::time::Duration;
7
8use anyhow::{anyhow, bail, Context, Result};
9use reqwest::blocking::{Client, Response};
10use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::config::CliConfig;
16
17const API_KEY_HEADER: &str = "X-API-KEY";
18const JSON_CONTENT_TYPE: &str = "application/json";
19
20/// Wrapper for ticket data in sync response
21#[derive(Debug, Deserialize)]
22pub struct TicketSyncData {
23    pub ticket: TicketSyncPayload,
24}
25
26/// Payload for ticket synchronization from the control plane
27#[derive(Debug, Deserialize)]
28pub struct TicketSyncPayload {
29    pub memory_id: Uuid,
30    #[serde(alias = "seq_no")]
31    pub sequence: i64,
32    pub issuer: String,
33    pub expires_in: u64,
34    #[serde(default)]
35    pub capacity_bytes: Option<u64>,
36    pub signature: String,
37}
38
39#[derive(Debug, Deserialize)]
40struct ApiEnvelope<T> {
41    status: String,
42    request_id: String,
43    data: Option<T>,
44    error: Option<ApiErrorBody>,
45    #[allow(dead_code)]
46    signature: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct ApiErrorBody {
51    code: String,
52    message: String,
53}
54
55pub struct TicketSyncResponse {
56    pub payload: TicketSyncPayload,
57    pub request_id: String,
58}
59
60/// Dashboard API response format (simpler than control plane)
61#[derive(Debug, Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct DashboardTicketResponse {
64    data: TicketSyncData,
65    request_id: String,
66}
67
68pub fn fetch_ticket(config: &CliConfig, memory_id: &Uuid) -> Result<TicketSyncResponse> {
69    let api_key = require_api_key(config)?;
70    let client = http_client()?;
71
72    // Use the dashboard URL for per-memory tickets
73    let base_url = std::env::var("MEMVID_DASHBOARD_URL")
74        .unwrap_or_else(|_| "https://memvid.com".to_string());
75    let url = format!(
76        "{}/api/memories/{}/tickets/sync",
77        base_url.trim_end_matches('/'),
78        memory_id
79    );
80
81    let response = client
82        .post(&url)
83        .headers(auth_headers(api_key)?)
84        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
85        .body("{}")
86        .send()
87        .with_context(|| format!("failed to contact ticket sync endpoint at {}", url))?;
88
89    let status = response.status();
90    if !status.is_success() {
91        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
92        if status.as_u16() == 401 {
93            bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
94        }
95        if status.as_u16() == 404 {
96            bail!("Memory not found. Create it first at https://memvid.com/dashboard");
97        }
98        if status.as_u16() == 403 {
99            bail!("You don't have access to this memory");
100        }
101        bail!("Ticket sync error ({}): {}", status, error_text);
102    }
103
104    let dashboard_response: DashboardTicketResponse = response.json()
105        .context("failed to parse ticket sync response")?;
106
107    Ok(TicketSyncResponse {
108        payload: dashboard_response.data.ticket,
109        request_id: dashboard_response.request_id,
110    })
111}
112
113#[derive(serde::Serialize)]
114pub struct ApplyTicketRequest<'a> {
115    pub issuer: &'a str,
116    pub seq_no: i64,
117    pub expires_in: u64,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub capacity_bytes: Option<u64>,
120    pub signature: &'a str,
121}
122
123pub fn apply_ticket(
124    config: &CliConfig,
125    memory_id: &Uuid,
126    request: &ApplyTicketRequest<'_>,
127) -> Result<String> {
128    let api_key = require_api_key(config)?;
129    let client = http_client()?;
130    let url = format!(
131        "{}/memories/{}/tickets/apply",
132        config.api_url.trim_end_matches('/'),
133        memory_id
134    );
135    let response = client
136        .post(url)
137        .headers(auth_headers(api_key)?)
138        .json(request)
139        .send()
140        .with_context(|| "failed to contact ticket apply endpoint")?;
141    let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
142    Ok(envelope.request_id)
143}
144
145#[derive(serde::Serialize)]
146pub struct RegisterFileRequest<'a> {
147    pub file_name: &'a str,
148    pub file_path: &'a str,
149    pub file_size: i64,
150    pub machine_id: &'a str,
151}
152
153/// Response from file registration
154#[derive(Debug, Deserialize)]
155pub struct RegisterFileResponse {
156    pub id: String,
157    pub memory_id: String,
158    pub file_name: String,
159    pub file_path: String,
160    pub file_size: i64,
161    pub machine_id: String,
162    pub last_synced: String,
163    pub created_at: String,
164}
165
166/// Dashboard file registration response format
167#[derive(Debug, Deserialize)]
168#[serde(rename_all = "camelCase")]
169struct DashboardFileResponse {
170    data: RegisterFileResponse,
171    request_id: String,
172}
173
174/// Register a file with the dashboard
175pub fn register_file(
176    _config: &CliConfig,
177    memory_id: &Uuid,
178    request: &RegisterFileRequest<'_>,
179    api_key: &str,
180) -> Result<RegisterFileResponse> {
181    let client = http_client()?;
182
183    // Use the dashboard URL for file registration
184    let base_url = std::env::var("MEMVID_DASHBOARD_URL")
185        .unwrap_or_else(|_| "https://memvid.com".to_string());
186    let url = format!(
187        "{}/api/memories/{}/files",
188        base_url.trim_end_matches('/'),
189        memory_id
190    );
191
192    let response = client
193        .post(&url)
194        .headers(auth_headers(api_key)?)
195        .json(request)
196        .send()
197        .with_context(|| "failed to contact file registration endpoint")?;
198
199    let status = response.status();
200    if !status.is_success() {
201        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
202        if status.as_u16() == 409 {
203            bail!("This memory is already bound to another file. Each memory can only be bound to one MV2 file.");
204        }
205        bail!("File registration error ({}): {}", status, error_text);
206    }
207
208    let dashboard_response: DashboardFileResponse = response.json()
209        .context("failed to parse file registration response")?;
210
211    Ok(dashboard_response.data)
212}
213
214fn http_client() -> Result<Client> {
215    crate::http::blocking_client(Duration::from_secs(15))
216}
217
218fn auth_headers(api_key: &str) -> Result<HeaderMap> {
219    let mut headers = HeaderMap::new();
220    let value = HeaderValue::from_str(api_key)
221        .map_err(|_| anyhow!("API key contains invalid characters"))?;
222    headers.insert(API_KEY_HEADER, value);
223    Ok(headers)
224}
225
226fn require_api_key(config: &CliConfig) -> Result<&str> {
227    config
228        .api_key
229        .as_deref()
230        .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
231}
232
233fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
234    let status = response.status();
235    let envelope = response.json::<ApiEnvelope<T>>()?;
236    if envelope.status == "ok" {
237        return Ok(envelope);
238    }
239
240    let message = envelope
241        .error
242        .map(|err| format!("{}: {}", err.code, err.message))
243        .unwrap_or_else(|| format!("request failed with status {}", status));
244    bail!(message);
245}
246
247// ===========================================================================
248// Org-level ticket API (for plan-based capacity validation)
249// ===========================================================================
250
251/// Organisation-level signed ticket from the dashboard
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct OrgTicket {
254    pub version: i32,
255    pub user_id: String,
256    pub org_id: String,
257    pub plan_id: String,
258    pub capacity_bytes: u64,
259    pub features: Vec<String>,
260    pub expires_at: i64,
261    pub signature: String,
262}
263
264impl OrgTicket {
265    /// Check if this ticket has expired
266    pub fn is_expired(&self) -> bool {
267        let now = std::time::SystemTime::now()
268            .duration_since(std::time::UNIX_EPOCH)
269            .map(|d| d.as_secs() as i64)
270            .unwrap_or(0);
271        self.expires_at < now
272    }
273
274    /// Check if the plan is a paid plan
275    pub fn is_paid(&self) -> bool {
276        matches!(self.plan_id.as_str(), "starter" | "pro" | "enterprise")
277    }
278
279    /// Get remaining time in seconds (0 if expired)
280    pub fn expires_in_secs(&self) -> u64 {
281        let now = std::time::SystemTime::now()
282            .duration_since(std::time::UNIX_EPOCH)
283            .map(|d| d.as_secs() as i64)
284            .unwrap_or(0);
285        if self.expires_at > now {
286            (self.expires_at - now) as u64
287        } else {
288            0
289        }
290    }
291}
292
293/// Plan information from the ticket response
294#[derive(Debug, Clone, Deserialize)]
295pub struct PlanInfo {
296    pub id: String,
297    pub name: String,
298    pub limits: PlanLimits,
299    pub features: Vec<String>,
300}
301
302/// Plan limits
303#[derive(Debug, Clone, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct PlanLimits {
306    pub capacity_bytes: u64,
307    #[serde(default)]
308    pub memory_files: Option<u64>,
309    #[serde(default)]
310    pub max_file_size: Option<u64>,
311}
312
313/// Subscription status information
314#[derive(Debug, Clone, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct SubscriptionInfo {
317    pub status: String,
318    pub expires_at: i64,
319}
320
321/// Organisation information
322#[derive(Debug, Clone, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct OrgInfo {
325    pub id: String,
326    pub name: String,
327    pub total_storage_bytes: u64,
328}
329
330/// Response from the /api/ticket endpoint
331#[derive(Debug, Clone, Deserialize)]
332pub struct OrgTicketResponse {
333    pub ticket: OrgTicket,
334    pub plan: PlanInfo,
335    pub subscription: SubscriptionInfo,
336    pub organisation: OrgInfo,
337}
338
339/// Fetch an org-level ticket from the dashboard API
340///
341/// This endpoint returns a signed ticket containing the user's plan capacity
342/// and features. The ticket can be used to validate large file operations
343/// and feature access.
344pub fn fetch_org_ticket(config: &CliConfig) -> Result<OrgTicketResponse> {
345    let api_key = require_api_key(config)?;
346    let client = http_client()?;
347
348    // Use the dashboard API URL (different from control plane)
349    let base_url = std::env::var("MEMVID_DASHBOARD_URL")
350        .unwrap_or_else(|_| "https://memvid.com".to_string());
351    let url = format!("{}/api/ticket", base_url.trim_end_matches('/'));
352
353    let response = client
354        .get(&url)
355        .headers(auth_headers(api_key)?)
356        .send()
357        .with_context(|| format!("failed to contact ticket endpoint at {}", url))?;
358
359    let status = response.status();
360    if !status.is_success() {
361        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
362        if status.as_u16() == 401 {
363            bail!(
364                "Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys"
365            );
366        }
367        bail!("Ticket API error ({}): {}", status, error_text);
368    }
369
370    let wrapper: serde_json::Value = response.json()
371        .context("failed to parse ticket response")?;
372
373    // Handle the API response wrapper
374    // The dashboard API returns: { "data": { "ticket": ..., "plan": ..., ... }, "requestId": ... }
375    let data = if let Some(data_field) = wrapper.get("data") {
376        data_field.clone()
377    } else if wrapper.get("status").and_then(|s| s.as_str()) == Some("ok") {
378        wrapper.get("data").cloned().unwrap_or(wrapper.clone())
379    } else if wrapper.get("ticket").is_some() {
380        // Direct response without wrapper
381        wrapper
382    } else {
383        wrapper
384    };
385
386    let ticket_response: OrgTicketResponse = serde_json::from_value(data)
387        .context("failed to parse ticket data")?;
388
389    Ok(ticket_response)
390}