memvid_cli/commands/
tickets.rs

1//! Ticket management command handlers (list, issue, revoke, sync, apply)
2
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use anyhow::{anyhow, bail, Context, Result};
7use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
8use chrono::Utc;
9use clap::{ArgAction, Args, Subcommand};
10use memvid_core::types::{MemoryBinding, SignedTicket};
11use memvid_core::{verify_ticket_signature, Memvid, Ticket};
12use serde_json::json;
13use uuid::Uuid;
14
15/// A memory ID that accepts both UUID (32 chars) and MongoDB ObjectId (24 chars) formats
16#[derive(Debug, Clone, Copy)]
17pub struct MemoryId(Uuid);
18
19impl serde::Serialize for MemoryId {
20    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
21    where
22        S: serde::Serializer,
23    {
24        self.0.serialize(serializer)
25    }
26}
27
28impl MemoryId {
29    pub fn as_uuid(&self) -> &Uuid {
30        &self.0
31    }
32}
33
34impl std::ops::Deref for MemoryId {
35    type Target = Uuid;
36    fn deref(&self) -> &Self::Target {
37        &self.0
38    }
39}
40
41impl std::fmt::Display for MemoryId {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "{}", self.0)
44    }
45}
46
47impl FromStr for MemoryId {
48    type Err = String;
49
50    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
51        // Try parsing as standard UUID first
52        if let Ok(uuid) = Uuid::parse_str(s) {
53            return Ok(MemoryId(uuid));
54        }
55
56        // If it's 24 chars (MongoDB ObjectId), pad with zeros to make it a valid UUID
57        if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
58            // Pad to 32 chars by appending zeros
59            let padded = format!("{}00000000", s);
60            if let Ok(uuid) = Uuid::parse_str(&padded) {
61                return Ok(MemoryId(uuid));
62            }
63        }
64
65        Err(format!(
66            "invalid memory ID '{}': expected UUID (32 chars) or ObjectId (24 chars)",
67            s
68        ))
69    }
70}
71
72use crate::api::{
73    apply_ticket as api_apply_ticket, fetch_ticket as api_fetch_ticket,
74    ApplyTicketRequest as ApiApplyTicketRequest,
75};
76use crate::commands::{apply_ticket_with_warning, LockCliArgs};
77use crate::config::CliConfig;
78use crate::ticket_cache::{load as load_cached_ticket, store as store_cached_ticket, CachedTicket};
79use crate::utils::{apply_lock_cli, open_read_only_mem};
80
81/// Subcommands for the `tickets` command
82#[derive(Subcommand)]
83pub enum TicketsCommand {
84    /// Show the currently applied ticket
85    List(TicketsStatusArgs),
86    /// Apply a new ticket
87    Issue(TicketsIssueArgs),
88    /// Clear ticket metadata while advancing the sequence
89    Revoke(TicketsRevokeArgs),
90    /// Synchronise the ticket state with the Memvid API
91    Sync(TicketsSyncArgs),
92    /// Submit a ticket fetched from the API back to the control plane
93    Apply(TicketsApplyArgs),
94}
95
96/// Arguments for the `tickets` command
97#[derive(Args)]
98pub struct TicketsArgs {
99    #[command(subcommand)]
100    pub command: TicketsCommand,
101}
102
103/// Arguments for the `tickets sync` subcommand
104#[derive(Args)]
105pub struct TicketsSyncArgs {
106    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
107    pub file: PathBuf,
108    #[arg(
109        long = "memory-id",
110        value_name = "ID",
111        help = "Memory ID (UUID or 24-char ObjectId)"
112    )]
113    pub memory_id: MemoryId,
114    #[arg(long)]
115    pub json: bool,
116
117    #[command(flatten)]
118    pub lock: LockCliArgs,
119}
120
121/// Arguments for the `tickets apply` subcommand
122#[derive(Args)]
123pub struct TicketsApplyArgs {
124    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
125    pub file: PathBuf,
126    #[arg(
127        long = "memory-id",
128        value_name = "ID",
129        help = "Memory ID (UUID or 24-char ObjectId)"
130    )]
131    pub memory_id: MemoryId,
132    #[arg(long = "from-api", action = ArgAction::SetTrue)]
133    pub from_api: bool,
134    #[arg(long)]
135    pub json: bool,
136}
137
138/// Arguments for the `tickets issue` subcommand
139#[derive(Args)]
140pub struct TicketsIssueArgs {
141    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
142    pub file: PathBuf,
143    #[arg(long)]
144    pub issuer: String,
145    #[arg(long, value_name = "SEQ")]
146    pub seq: i64,
147    #[arg(long = "expires-in", value_name = "SECS")]
148    pub expires_in: Option<u64>,
149    #[arg(long, value_name = "BYTES")]
150    pub capacity: Option<u64>,
151    #[arg(long)]
152    pub json: bool,
153
154    #[command(flatten)]
155    pub lock: LockCliArgs,
156}
157
158/// Arguments for the `tickets revoke` subcommand
159#[derive(Args)]
160pub struct TicketsRevokeArgs {
161    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
162    pub file: PathBuf,
163    #[arg(long)]
164    pub json: bool,
165
166    #[command(flatten)]
167    pub lock: LockCliArgs,
168}
169
170/// Arguments for the `tickets list` subcommand
171#[derive(Args)]
172pub struct TicketsStatusArgs {
173    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
174    pub file: PathBuf,
175    #[arg(long)]
176    pub json: bool,
177}
178
179// ============================================================================
180// Tickets command handlers
181// ============================================================================
182
183pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
184    match args.command {
185        TicketsCommand::List(status) => handle_ticket_status(status),
186        TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
187        TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
188        TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
189        TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
190    }
191}
192
193fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
194    config
195        .ticket_pubkey
196        .as_ref()
197        .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
198}
199
200fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
201    json!({
202        "issuer": ticket.issuer,
203        "seq_no": ticket.seq_no,
204        "expires_in_secs": ticket.expires_in_secs,
205        "capacity_bytes": ticket.capacity_bytes,
206        "verified": ticket.verified,
207    })
208}
209
210pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
211    let mem = open_read_only_mem(&args.file)?;
212    let ticket = mem.current_ticket();
213    if args.json {
214        println!(
215            "{}",
216            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
217        );
218    } else {
219        println!("Ticket issuer: {}", ticket.issuer);
220        println!("Sequence: {}", ticket.seq_no);
221        println!("Expires in (secs): {}", ticket.expires_in_secs);
222        if ticket.capacity_bytes != 0 {
223            println!("Capacity bytes: {}", ticket.capacity_bytes);
224        } else {
225            println!("Capacity bytes: (tier default)");
226        }
227    }
228    Ok(())
229}
230
231pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
232    let mut mem = Memvid::open(&args.file)?;
233    apply_lock_cli(&mut mem, &args.lock);
234    let mut ticket = Ticket::new(&args.issuer, args.seq);
235    if let Some(expires) = args.expires_in {
236        ticket = ticket.expires_in_secs(expires);
237    }
238    if let Some(capacity) = args.capacity {
239        ticket = ticket.capacity_bytes(capacity);
240    }
241    apply_ticket_with_warning(&mut mem, ticket)?;
242    let ticket = mem.current_ticket();
243    if args.json {
244        println!(
245            "{}",
246            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
247        );
248    } else {
249        println!(
250            "Applied ticket seq={} issuer={}",
251            ticket.seq_no, ticket.issuer
252        );
253    }
254    Ok(())
255}
256
257pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
258    let mut mem = Memvid::open(&args.file)?;
259    apply_lock_cli(&mut mem, &args.lock);
260    let current = mem.current_ticket();
261    let next_seq = current.seq_no.saturating_add(1).max(1);
262    let ticket = Ticket::new("", next_seq);
263    apply_ticket_with_warning(&mut mem, ticket)?;
264    let ticket = mem.current_ticket();
265    if args.json {
266        println!(
267            "{}",
268            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
269        );
270    } else {
271        println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
272    }
273    Ok(())
274}
275
276pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
277    let pubkey = ticket_public_key(config)?;
278    let response = api_fetch_ticket(config, &args.memory_id)?;
279
280    // Get file info for registration
281    let file_path = std::fs::canonicalize(&args.file)
282        .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
283    let file_metadata = std::fs::metadata(&args.file)
284        .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
285    let file_size = file_metadata.len() as i64;
286    let file_name = args
287        .file
288        .file_name()
289        .and_then(|n| n.to_str())
290        .unwrap_or("unknown.mv2");
291    let machine_id = hostname::get()
292        .map(|h| h.to_string_lossy().to_string())
293        .unwrap_or_else(|_| "unknown".to_string());
294    let signature_bytes = BASE64_STANDARD
295        .decode(response.payload.signature.as_bytes())
296        .context("ticket signature is not valid base64")?;
297
298    // Extract values from response
299    let issuer = response.payload.issuer.clone();
300    let seq_no = response.payload.sequence;
301    let expires_in = response.payload.expires_in;
302    let capacity_bytes = response.payload.capacity_bytes;
303
304    verify_ticket_signature(
305        pubkey,
306        &args.memory_id,
307        &issuer,
308        seq_no,
309        expires_in,
310        capacity_bytes,
311        &signature_bytes,
312    )
313    .context("ticket signature verification failed")?;
314
315    let mut mem = Memvid::open(&args.file)?;
316    apply_lock_cli(&mut mem, &args.lock);
317
318    // Check if the file already has this ticket or a newer one
319    let current = mem.current_ticket();
320    if current.seq_no >= seq_no {
321        // Already bound with same or newer ticket
322        let capacity = mem.get_capacity();
323
324        // Still register/update file with the dashboard
325        let file_path_str = file_path.to_string_lossy();
326        let register_request = crate::api::RegisterFileRequest {
327            file_name,
328            file_path: &file_path_str,
329            file_size,
330            machine_id: &machine_id,
331        };
332        if let Some(api_key) = config.api_key.as_deref() {
333            if let Err(e) =
334                crate::api::register_file(config, &args.memory_id, &register_request, api_key)
335            {
336                log::warn!("Failed to register file with dashboard: {}", e);
337            }
338        }
339
340        if args.json {
341            let json = json!({
342                "issuer": current.issuer,
343                "seq_no": current.seq_no,
344                "expires_in": current.expires_in_secs,
345                "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
346                "memory_id": args.memory_id,
347                "verified": current.verified,
348                "already_bound": true,
349            });
350            println!("{}", serde_json::to_string_pretty(&json)?);
351        } else {
352            let verified_str = if current.verified { " ✓" } else { "" };
353            println!(
354                "Already bound to memory {} (seq={}, issuer={}{})",
355                args.memory_id, current.seq_no, current.issuer, verified_str
356            );
357            println!(
358                "Current capacity: {:.2} GB",
359                capacity as f64 / 1024.0 / 1024.0 / 1024.0
360            );
361        }
362        return Ok(());
363    }
364
365    // Register file with the dashboard FIRST (before binding locally)
366    // This ensures only one file can be bound to each memory
367    let file_path_str = file_path.to_string_lossy();
368    let register_request = crate::api::RegisterFileRequest {
369        file_name,
370        file_path: &file_path_str,
371        file_size,
372        machine_id: &machine_id,
373    };
374    if let Some(api_key) = config.api_key.as_deref() {
375        crate::api::register_file(config, &args.memory_id, &register_request, api_key)
376            .context("failed to register file with dashboard - this memory may already be bound to another file")?;
377    } else {
378        bail!("API key required for binding. Set MEMVID_API_KEY.");
379    }
380
381    // Create memory binding first if not already bound
382    let binding = MemoryBinding {
383        memory_id: *args.memory_id,
384        memory_name: issuer.clone(), // Use issuer as name for now
385        bound_at: Utc::now(),
386        api_url: config.api_url.clone(),
387    };
388
389    // Ensure memory is bound before applying signed ticket
390    if mem.get_memory_binding().is_none() {
391        // Create a temporary ticket for binding (seq-1 to allow signed ticket to apply)
392        let temp_ticket =
393            Ticket::new(&issuer, seq_no.saturating_sub(1)).expires_in_secs(expires_in);
394        mem.bind_memory(binding, temp_ticket)
395            .context("failed to bind memory")?;
396    }
397
398    // Create and apply signed ticket with cryptographic verification
399    let signed_ticket = SignedTicket::new(
400        &issuer,
401        seq_no,
402        expires_in,
403        capacity_bytes,
404        *args.memory_id,
405        signature_bytes,
406    );
407
408    mem.apply_signed_ticket(signed_ticket)
409        .context("failed to apply signed ticket")?;
410    mem.commit()?;
411
412    let cache_entry = CachedTicket {
413        memory_id: *args.memory_id,
414        issuer: issuer.clone(),
415        seq_no,
416        expires_in,
417        capacity_bytes,
418        signature: response.payload.signature.clone(),
419    };
420    store_cached_ticket(config, &cache_entry)?;
421
422    let capacity = mem.get_capacity();
423
424    if args.json {
425        let json = json!({
426            "issuer": issuer,
427            "seq_no": seq_no,
428            "expires_in": expires_in,
429            "capacity_bytes": capacity_bytes,
430            "memory_id": args.memory_id,
431            "request_id": response.request_id,
432            "verified": true,
433        });
434        println!("{}", serde_json::to_string_pretty(&json)?);
435    } else {
436        println!(
437            "Bound to memory {} (seq={}, issuer={}) ✓",
438            args.memory_id, seq_no, issuer
439        );
440        println!(
441            "New capacity: {:.2} GB",
442            capacity as f64 / 1024.0 / 1024.0 / 1024.0
443        );
444    }
445    Ok(())
446}
447
448pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
449    if !args.from_api {
450        bail!("use --from-api to submit the ticket fetched from the API");
451    }
452    let cached = load_cached_ticket(config, &args.memory_id)
453        .context("no cached ticket available; run `tickets sync` first")?;
454    let request = ApiApplyTicketRequest {
455        issuer: &cached.issuer,
456        seq_no: cached.seq_no,
457        expires_in: cached.expires_in,
458        capacity_bytes: cached.capacity_bytes,
459        signature: &cached.signature,
460    };
461    let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
462    if args.json {
463        let json = json!({
464            "memory_id": args.memory_id,
465            "seq_no": cached.seq_no,
466            "request_id": request_id,
467        });
468        println!("{}", serde_json::to_string_pretty(&json)?);
469    } else {
470        println!(
471            "Submitted ticket seq={} for memory {} (request {})",
472            cached.seq_no, args.memory_id, request_id
473        );
474    }
475    Ok(())
476}