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;
16use crate::commands::config::PersistentConfig;
17
18const API_KEY_HEADER: &str = "X-API-KEY";
19const JSON_CONTENT_TYPE: &str = "application/json";
20const DEFAULT_DASHBOARD_URL: &str = "https://memvid.com";
21
22/// Get the dashboard URL from environment or config file
23fn get_dashboard_url() -> String {
24    // Priority: env var > config file > default
25    if let Ok(url) = std::env::var("MEMVID_DASHBOARD_URL") {
26        if !url.trim().is_empty() {
27            return url;
28        }
29    }
30    if let Ok(config) = PersistentConfig::load() {
31        if let Some(url) = config.dashboard_url {
32            return url;
33        }
34    }
35    DEFAULT_DASHBOARD_URL.to_string()
36}
37
38/// Wrapper for ticket data in sync response
39#[derive(Debug, Deserialize)]
40pub struct TicketSyncData {
41    pub ticket: TicketSyncPayload,
42}
43
44/// Payload for ticket synchronization from the control plane
45#[derive(Debug, Deserialize)]
46pub struct TicketSyncPayload {
47    pub memory_id: Uuid,
48    #[serde(alias = "seq_no")]
49    pub sequence: i64,
50    pub issuer: String,
51    pub expires_in: u64,
52    #[serde(default)]
53    pub capacity_bytes: Option<u64>,
54    pub signature: String,
55}
56
57#[derive(Debug, Deserialize)]
58#[allow(dead_code)]
59struct ApiEnvelope<T> {
60    status: String,
61    request_id: String,
62    data: Option<T>,
63    error: Option<ApiErrorBody>,
64    signature: String,
65}
66
67#[derive(Debug, Deserialize)]
68struct ApiErrorBody {
69    code: String,
70    message: String,
71}
72
73pub struct TicketSyncResponse {
74    pub payload: TicketSyncPayload,
75    pub request_id: String,
76}
77
78/// Dashboard API response format (simpler than control plane)
79#[derive(Debug, Deserialize)]
80#[serde(rename_all = "camelCase")]
81struct DashboardTicketResponse {
82    data: TicketSyncData,
83    request_id: String,
84}
85
86pub fn fetch_ticket(config: &CliConfig, memory_id: &Uuid) -> Result<TicketSyncResponse> {
87    let api_key = require_api_key(config)?;
88    let client = http_client()?;
89
90    // Use the dashboard URL for per-memory tickets
91    let base_url = get_dashboard_url();
92    // Handle both http://localhost:3001 and http://localhost:3001/api
93    let base = base_url.trim_end_matches('/').trim_end_matches("/api");
94    let url = format!(
95        "{}/api/memories/{}/tickets/sync",
96        base,
97        memory_id
98    );
99
100    let response = client
101        .post(&url)
102        .headers(auth_headers(api_key)?)
103        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
104        .body("{}")
105        .send()
106        .with_context(|| format!("failed to contact ticket sync endpoint at {}", url))?;
107
108    let status = response.status();
109    if !status.is_success() {
110        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
111        if status.as_u16() == 401 {
112            bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
113        }
114        if status.as_u16() == 404 {
115            bail!("Memory not found. Create it first at https://memvid.com/dashboard");
116        }
117        if status.as_u16() == 403 {
118            bail!("You don't have access to this memory");
119        }
120        bail!("Ticket sync error ({}): {}", status, error_text);
121    }
122
123    let dashboard_response: DashboardTicketResponse = response.json()
124        .context("failed to parse ticket sync response")?;
125
126    Ok(TicketSyncResponse {
127        payload: dashboard_response.data.ticket,
128        request_id: dashboard_response.request_id,
129    })
130}
131
132#[derive(serde::Serialize)]
133pub struct ApplyTicketRequest<'a> {
134    pub issuer: &'a str,
135    pub seq_no: i64,
136    pub expires_in: u64,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub capacity_bytes: Option<u64>,
139    pub signature: &'a str,
140}
141
142pub fn apply_ticket(
143    config: &CliConfig,
144    memory_id: &Uuid,
145    request: &ApplyTicketRequest<'_>,
146) -> Result<String> {
147    let api_key = require_api_key(config)?;
148    let client = http_client()?;
149    let url = format!(
150        "{}/memories/{}/tickets/apply",
151        config.api_url.trim_end_matches('/'),
152        memory_id
153    );
154    let response = client
155        .post(url)
156        .headers(auth_headers(api_key)?)
157        .json(request)
158        .send()
159        .with_context(|| "failed to contact ticket apply endpoint")?;
160    let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
161    Ok(envelope.request_id)
162}
163
164#[derive(Debug, Serialize)]
165pub struct RegisterFileRequest<'a> {
166    pub file_name: &'a str,
167    pub file_path: &'a str,
168    pub file_size: i64,
169    pub machine_id: &'a str,
170}
171
172/// Response from file registration
173#[derive(Debug, Deserialize)]
174pub struct RegisterFileResponse {
175    pub id: String,
176    pub memory_id: String,
177    pub file_name: String,
178    pub file_path: String,
179    pub file_size: i64,
180    pub machine_id: String,
181    pub last_synced: String,
182    pub created_at: String,
183}
184
185/// Dashboard file registration response format
186#[derive(Debug, Deserialize)]
187#[serde(rename_all = "camelCase")]
188#[allow(dead_code)]
189struct DashboardFileResponse {
190    data: RegisterFileResponse,
191    request_id: String,
192}
193
194/// Register a file with the dashboard
195pub fn register_file(
196    _config: &CliConfig,
197    memory_id: &Uuid,
198    request: &RegisterFileRequest<'_>,
199    api_key: &str,
200) -> Result<RegisterFileResponse> {
201    let client = http_client()?;
202
203    // Use the dashboard URL for file registration
204    let base_url = get_dashboard_url();
205    let url = format!(
206        "{}/api/memories/{}/files",
207        base_url.trim_end_matches('/'),
208        memory_id
209    );
210
211    let response = client
212        .post(&url)
213        .headers(auth_headers(api_key)?)
214        .json(request)
215        .send()
216        .with_context(|| "failed to contact file registration endpoint")?;
217
218    let status = response.status();
219    if !status.is_success() {
220        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
221        if status.as_u16() == 409 {
222            // Try to extract the specific message from the API response
223            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_text) {
224                if let Some(msg) = json.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()) {
225                    bail!("{}", msg);
226                }
227            }
228            bail!("This memory is already bound to another file. Each memory can only be bound to one MV2 file.");
229        }
230        bail!("File registration error ({}): {}", status, error_text);
231    }
232
233    let dashboard_response: DashboardFileResponse = response.json()
234        .context("failed to parse file registration response")?;
235
236    Ok(dashboard_response.data)
237}
238
239fn http_client() -> Result<Client> {
240    crate::http::blocking_client(Duration::from_secs(15))
241}
242
243fn auth_headers(api_key: &str) -> Result<HeaderMap> {
244    let mut headers = HeaderMap::new();
245    let value = HeaderValue::from_str(api_key)
246        .map_err(|_| anyhow!("API key contains invalid characters"))?;
247    headers.insert(API_KEY_HEADER, value);
248    Ok(headers)
249}
250
251fn require_api_key(config: &CliConfig) -> Result<&str> {
252    config
253        .api_key
254        .as_deref()
255        .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
256}
257
258fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
259    let status = response.status();
260    let envelope = response.json::<ApiEnvelope<T>>()?;
261    if envelope.status == "ok" {
262        return Ok(envelope);
263    }
264
265    let message = envelope
266        .error
267        .map(|err| format!("{}: {}", err.code, err.message))
268        .unwrap_or_else(|| format!("request failed with status {}", status));
269    bail!(message);
270}
271
272// ===========================================================================
273// Org-level ticket API (for plan-based capacity validation)
274// ===========================================================================
275
276/// Organisation-level signed ticket from the dashboard
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct OrgTicket {
279    pub version: i32,
280    pub user_id: String,
281    pub org_id: String,
282    pub plan_id: String,
283    pub capacity_bytes: u64,
284    pub features: Vec<String>,
285    pub expires_at: i64,
286    pub signature: String,
287}
288
289impl OrgTicket {
290    /// Check if this ticket has expired
291    pub fn is_expired(&self) -> bool {
292        let now = std::time::SystemTime::now()
293            .duration_since(std::time::UNIX_EPOCH)
294            .map(|d| d.as_secs() as i64)
295            .unwrap_or(0);
296        self.expires_at < now
297    }
298
299    /// Check if the plan is a paid plan
300    pub fn is_paid(&self) -> bool {
301        matches!(self.plan_id.as_str(), "starter" | "pro" | "enterprise")
302    }
303
304    /// Get remaining time in seconds (0 if expired)
305    pub fn expires_in_secs(&self) -> u64 {
306        let now = std::time::SystemTime::now()
307            .duration_since(std::time::UNIX_EPOCH)
308            .map(|d| d.as_secs() as i64)
309            .unwrap_or(0);
310        if self.expires_at > now {
311            (self.expires_at - now) as u64
312        } else {
313            0
314        }
315    }
316}
317
318/// Plan information from the ticket response
319#[derive(Debug, Clone, Deserialize)]
320pub struct PlanInfo {
321    pub id: String,
322    pub name: String,
323    pub limits: PlanLimits,
324    pub features: Vec<String>,
325}
326
327/// Plan limits
328#[derive(Debug, Clone, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct PlanLimits {
331    pub capacity_bytes: u64,
332    #[serde(default)]
333    pub memory_files: Option<u64>,
334    #[serde(default)]
335    pub max_file_size: Option<u64>,
336}
337
338/// Subscription status information
339#[derive(Debug, Clone, Deserialize)]
340#[serde(rename_all = "camelCase")]
341pub struct SubscriptionInfo {
342    pub status: String,
343    pub expires_at: i64,
344    /// ISO date when paid plan started
345    #[serde(default)]
346    pub plan_start_date: Option<String>,
347    /// ISO date when current billing period ends (renews)
348    #[serde(default)]
349    pub current_period_end: Option<String>,
350    /// ISO date when paid plan ends (for canceled subscriptions in grace period)
351    #[serde(default)]
352    pub plan_end_date: Option<String>,
353}
354
355/// Organisation information
356#[derive(Debug, Clone, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct OrgInfo {
359    pub id: String,
360    pub name: String,
361    pub total_storage_bytes: u64,
362}
363
364/// Response from the /api/ticket endpoint
365#[derive(Debug, Clone, Deserialize)]
366pub struct OrgTicketResponse {
367    pub ticket: OrgTicket,
368    pub plan: PlanInfo,
369    pub subscription: SubscriptionInfo,
370    pub organisation: OrgInfo,
371}
372
373/// Fetch an org-level ticket from the dashboard API
374///
375/// This endpoint returns a signed ticket containing the user's plan capacity
376/// and features. The ticket can be used to validate large file operations
377/// and feature access.
378pub fn fetch_org_ticket(config: &CliConfig) -> Result<OrgTicketResponse> {
379    let api_key = require_api_key(config)?;
380    let client = http_client()?;
381
382    // Use the dashboard API URL (different from control plane)
383    let base_url = get_dashboard_url();
384    // Handle both http://localhost:3001 and http://localhost:3001/api
385    let base = base_url.trim_end_matches('/').trim_end_matches("/api");
386    let url = format!("{}/api/ticket", base);
387
388    let response = client
389        .get(&url)
390        .headers(auth_headers(api_key)?)
391        .send()
392        .with_context(|| format!("failed to contact ticket endpoint at {}", url))?;
393
394    let status = response.status();
395    if !status.is_success() {
396        let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
397        if status.as_u16() == 401 {
398            bail!(
399                "Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys"
400            );
401        }
402        bail!("Ticket API error ({}): {}", status, error_text);
403    }
404
405    let wrapper: serde_json::Value = response.json()
406        .context("failed to parse ticket response")?;
407
408    // Handle the API response wrapper
409    // The dashboard API returns: { "data": { "ticket": ..., "plan": ..., ... }, "requestId": ... }
410    let data = if let Some(data_field) = wrapper.get("data") {
411        data_field.clone()
412    } else if wrapper.get("status").and_then(|s| s.as_str()) == Some("ok") {
413        wrapper.get("data").cloned().unwrap_or(wrapper.clone())
414    } else if wrapper.get("ticket").is_some() {
415        // Direct response without wrapper
416        wrapper
417    } else {
418        wrapper
419    };
420
421    let ticket_response: OrgTicketResponse = serde_json::from_value(data)
422        .context("failed to parse ticket data")?;
423
424    Ok(ticket_response)
425}
426
427// ===========================================================================
428// Query Usage Tracking
429// ===========================================================================
430
431/// Response from the /api/v1/query endpoint
432#[derive(Debug, Clone, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct QueryTrackingResponse {
435    pub success: bool,
436    #[serde(default)]
437    pub queries_used: Option<u64>,
438    #[serde(default)]
439    pub queries_limit: Option<u64>,
440    #[serde(default)]
441    pub queries_remaining: Option<u64>,
442}
443
444/// Track a query against the user's plan quota.
445///
446/// This is called before find() and ask() operations to record usage.
447/// Returns Ok(()) on success, or an error if quota is exceeded.
448/// Network errors are logged but don't fail the operation (best-effort tracking).
449pub fn track_query_usage(config: &CliConfig, count: u64) -> Result<()> {
450    let api_key = match require_api_key(config) {
451        Ok(key) => key,
452        Err(_) => {
453            // No API key configured - skip tracking
454            return Ok(());
455        }
456    };
457
458    let client = http_client()?;
459    let base_url = get_dashboard_url();
460    let url = format!("{}/api/v1/query", base_url);
461
462    let body = serde_json::json!({ "count": count });
463
464    let response = match client
465        .post(&url)
466        .header("Content-Type", "application/json")
467        .header("X-API-Key", &*api_key)
468        .timeout(std::time::Duration::from_secs(5))
469        .body(body.to_string())
470        .send()
471    {
472        Ok(resp) => resp,
473        Err(e) => {
474            // Network error - log but don't fail the query
475            log::warn!("Query tracking failed: {}", e);
476            return Ok(());
477        }
478    };
479
480    let status = response.status();
481
482    if status.as_u16() == 429 {
483        // Quota exceeded - this is a hard error
484        let body_text = response.text().unwrap_or_default();
485
486        // Try to parse error details
487        if let Ok(data) = serde_json::from_str::<serde_json::Value>(&body_text) {
488            let message = data.get("message")
489                .and_then(|v| v.as_str())
490                .unwrap_or("Monthly query quota exceeded");
491            let limit = data.get("limit").and_then(|v| v.as_u64());
492            let used = data.get("used").and_then(|v| v.as_u64());
493            let reset_date = data.get("resetDate").and_then(|v| v.as_str());
494
495            let mut error_msg = format!("{}", message);
496            if let (Some(used), Some(limit)) = (used, limit) {
497                error_msg.push_str(&format!("\nUsed: {} / {}", used, limit));
498            }
499            if let Some(reset) = reset_date {
500                error_msg.push_str(&format!("\nResets: {}", reset));
501            }
502            error_msg.push_str(&format!("\nUpgrade at: {}/dashboard/plan", base_url));
503
504            bail!(error_msg);
505        } else {
506            bail!("Monthly query quota exceeded. Upgrade at: {}/dashboard/plan", base_url);
507        }
508    }
509
510    if !status.is_success() {
511        // Other error - log but don't fail
512        log::warn!("Query tracking returned status {}", status);
513    }
514
515    Ok(())
516}