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