Skip to main content

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