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(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
109    pub memory_id: MemoryId,
110    #[arg(long)]
111    pub json: bool,
112
113    #[command(flatten)]
114    pub lock: LockCliArgs,
115}
116
117/// Arguments for the `tickets apply` subcommand
118#[derive(Args)]
119pub struct TicketsApplyArgs {
120    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
121    pub file: PathBuf,
122    #[arg(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
123    pub memory_id: MemoryId,
124    #[arg(long = "from-api", action = ArgAction::SetTrue)]
125    pub from_api: bool,
126    #[arg(long)]
127    pub json: bool,
128}
129
130/// Arguments for the `tickets issue` subcommand
131#[derive(Args)]
132pub struct TicketsIssueArgs {
133    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
134    pub file: PathBuf,
135    #[arg(long)]
136    pub issuer: String,
137    #[arg(long, value_name = "SEQ")]
138    pub seq: i64,
139    #[arg(long = "expires-in", value_name = "SECS")]
140    pub expires_in: Option<u64>,
141    #[arg(long, value_name = "BYTES")]
142    pub capacity: Option<u64>,
143    #[arg(long)]
144    pub json: bool,
145
146    #[command(flatten)]
147    pub lock: LockCliArgs,
148}
149
150/// Arguments for the `tickets revoke` subcommand
151#[derive(Args)]
152pub struct TicketsRevokeArgs {
153    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
154    pub file: PathBuf,
155    #[arg(long)]
156    pub json: bool,
157
158    #[command(flatten)]
159    pub lock: LockCliArgs,
160}
161
162/// Arguments for the `tickets list` subcommand
163#[derive(Args)]
164pub struct TicketsStatusArgs {
165    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
166    pub file: PathBuf,
167    #[arg(long)]
168    pub json: bool,
169}
170
171// ============================================================================
172// Tickets command handlers
173// ============================================================================
174
175pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
176    match args.command {
177        TicketsCommand::List(status) => handle_ticket_status(status),
178        TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
179        TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
180        TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
181        TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
182    }
183}
184
185fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
186    config
187        .ticket_pubkey
188        .as_ref()
189        .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
190}
191
192fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
193    json!({
194        "issuer": ticket.issuer,
195        "seq_no": ticket.seq_no,
196        "expires_in_secs": ticket.expires_in_secs,
197        "capacity_bytes": ticket.capacity_bytes,
198        "verified": ticket.verified,
199    })
200}
201
202pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
203    let mem = open_read_only_mem(&args.file)?;
204    let ticket = mem.current_ticket();
205    if args.json {
206        println!(
207            "{}",
208            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
209        );
210    } else {
211        println!("Ticket issuer: {}", ticket.issuer);
212        println!("Sequence: {}", ticket.seq_no);
213        println!("Expires in (secs): {}", ticket.expires_in_secs);
214        if ticket.capacity_bytes != 0 {
215            println!("Capacity bytes: {}", ticket.capacity_bytes);
216        } else {
217            println!("Capacity bytes: (tier default)");
218        }
219    }
220    Ok(())
221}
222
223pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
224    let mut mem = Memvid::open(&args.file)?;
225    apply_lock_cli(&mut mem, &args.lock);
226    let mut ticket = Ticket::new(&args.issuer, args.seq);
227    if let Some(expires) = args.expires_in {
228        ticket = ticket.expires_in_secs(expires);
229    }
230    if let Some(capacity) = args.capacity {
231        ticket = ticket.capacity_bytes(capacity);
232    }
233    apply_ticket_with_warning(&mut mem, ticket)?;
234    let ticket = mem.current_ticket();
235    if args.json {
236        println!(
237            "{}",
238            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
239        );
240    } else {
241        println!(
242            "Applied ticket seq={} issuer={}",
243            ticket.seq_no, ticket.issuer
244        );
245    }
246    Ok(())
247}
248
249pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
250    let mut mem = Memvid::open(&args.file)?;
251    apply_lock_cli(&mut mem, &args.lock);
252    let current = mem.current_ticket();
253    let next_seq = current.seq_no.saturating_add(1).max(1);
254    let ticket = Ticket::new("", next_seq);
255    apply_ticket_with_warning(&mut mem, ticket)?;
256    let ticket = mem.current_ticket();
257    if args.json {
258        println!(
259            "{}",
260            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
261        );
262    } else {
263        println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
264    }
265    Ok(())
266}
267
268pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
269    let pubkey = ticket_public_key(config)?;
270    let response = api_fetch_ticket(config, &args.memory_id)?;
271
272    // Get file info for registration
273    let file_path = std::fs::canonicalize(&args.file)
274        .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
275    let file_metadata = std::fs::metadata(&args.file)
276        .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
277    let file_size = file_metadata.len() as i64;
278    let file_name = args
279        .file
280        .file_name()
281        .and_then(|n| n.to_str())
282        .unwrap_or("unknown.mv2");
283    let machine_id = hostname::get()
284        .map(|h| h.to_string_lossy().to_string())
285        .unwrap_or_else(|_| "unknown".to_string());
286    let signature_bytes = BASE64_STANDARD
287        .decode(response.payload.signature.as_bytes())
288        .context("ticket signature is not valid base64")?;
289
290    // Extract values from response
291    let issuer = response.payload.issuer.clone();
292    let seq_no = response.payload.sequence;
293    let expires_in = response.payload.expires_in;
294    let capacity_bytes = response.payload.capacity_bytes;
295
296    verify_ticket_signature(
297        pubkey,
298        &args.memory_id,
299        &issuer,
300        seq_no,
301        expires_in,
302        capacity_bytes,
303        &signature_bytes,
304    )
305    .context("ticket signature verification failed")?;
306
307    let mut mem = Memvid::open(&args.file)?;
308    apply_lock_cli(&mut mem, &args.lock);
309
310    // Check if the file already has this ticket or a newer one
311    let current = mem.current_ticket();
312    if current.seq_no >= seq_no {
313        // Already bound with same or newer ticket
314        let capacity = mem.get_capacity();
315
316        // Still register/update file with the dashboard
317        let file_path_str = file_path.to_string_lossy();
318        let register_request = crate::api::RegisterFileRequest {
319            file_name,
320            file_path: &file_path_str,
321            file_size,
322            machine_id: &machine_id,
323        };
324        if let Some(api_key) = config.api_key.as_deref() {
325            if let Err(e) = crate::api::register_file(config, &args.memory_id, &register_request, api_key) {
326                log::warn!("Failed to register file with dashboard: {}", e);
327            }
328        }
329
330        if args.json {
331            let json = json!({
332                "issuer": current.issuer,
333                "seq_no": current.seq_no,
334                "expires_in": current.expires_in_secs,
335                "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
336                "memory_id": args.memory_id,
337                "verified": current.verified,
338                "already_bound": true,
339            });
340            println!("{}", serde_json::to_string_pretty(&json)?);
341        } else {
342            let verified_str = if current.verified { " ✓" } else { "" };
343            println!(
344                "Already bound to memory {} (seq={}, issuer={}{})",
345                args.memory_id, current.seq_no, current.issuer, verified_str
346            );
347            println!(
348                "Current capacity: {:.2} GB",
349                capacity as f64 / 1024.0 / 1024.0 / 1024.0
350            );
351        }
352        return Ok(());
353    }
354
355    // Register file with the dashboard FIRST (before binding locally)
356    // This ensures only one file can be bound to each memory
357    let file_path_str = file_path.to_string_lossy();
358    let register_request = crate::api::RegisterFileRequest {
359        file_name,
360        file_path: &file_path_str,
361        file_size,
362        machine_id: &machine_id,
363    };
364    if let Some(api_key) = config.api_key.as_deref() {
365        crate::api::register_file(config, &args.memory_id, &register_request, api_key)
366            .context("failed to register file with dashboard - this memory may already be bound to another file")?;
367    } else {
368        bail!("API key required for binding. Set MEMVID_API_KEY.");
369    }
370
371    // Create memory binding first if not already bound
372    let binding = MemoryBinding {
373        memory_id: *args.memory_id,
374        memory_name: issuer.clone(), // Use issuer as name for now
375        bound_at: Utc::now(),
376        api_url: config.api_url.clone(),
377    };
378
379    // Ensure memory is bound before applying signed ticket
380    if mem.get_memory_binding().is_none() {
381        // Create a temporary ticket for binding (seq-1 to allow signed ticket to apply)
382        let temp_ticket = Ticket::new(&issuer, seq_no.saturating_sub(1)).expires_in_secs(expires_in);
383        mem.bind_memory(binding, temp_ticket)
384            .context("failed to bind memory")?;
385    }
386
387    // Create and apply signed ticket with cryptographic verification
388    let signed_ticket = SignedTicket::new(
389        &issuer,
390        seq_no,
391        expires_in,
392        capacity_bytes,
393        *args.memory_id,
394        signature_bytes,
395    );
396
397    mem.apply_signed_ticket(signed_ticket)
398        .context("failed to apply signed ticket")?;
399    mem.commit()?;
400
401    let cache_entry = CachedTicket {
402        memory_id: *args.memory_id,
403        issuer: issuer.clone(),
404        seq_no,
405        expires_in,
406        capacity_bytes,
407        signature: response.payload.signature.clone(),
408    };
409    store_cached_ticket(config, &cache_entry)?;
410
411    let capacity = mem.get_capacity();
412
413    if args.json {
414        let json = json!({
415            "issuer": issuer,
416            "seq_no": seq_no,
417            "expires_in": expires_in,
418            "capacity_bytes": capacity_bytes,
419            "memory_id": args.memory_id,
420            "request_id": response.request_id,
421            "verified": true,
422        });
423        println!("{}", serde_json::to_string_pretty(&json)?);
424    } else {
425        println!(
426            "Bound to memory {} (seq={}, issuer={}) ✓",
427            args.memory_id, seq_no, issuer
428        );
429        println!(
430            "New capacity: {:.2} GB",
431            capacity as f64 / 1024.0 / 1024.0 / 1024.0
432        );
433    }
434    Ok(())
435}
436
437pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
438    if !args.from_api {
439        bail!("use --from-api to submit the ticket fetched from the API");
440    }
441    let cached = load_cached_ticket(config, &args.memory_id)
442        .context("no cached ticket available; run `tickets sync` first")?;
443    let request = ApiApplyTicketRequest {
444        issuer: &cached.issuer,
445        seq_no: cached.seq_no,
446        expires_in: cached.expires_in,
447        capacity_bytes: cached.capacity_bytes,
448        signature: &cached.signature,
449    };
450    let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
451    if args.json {
452        let json = json!({
453            "memory_id": args.memory_id,
454            "seq_no": cached.seq_no,
455            "request_id": request_id,
456        });
457        println!("{}", serde_json::to_string_pretty(&json)?);
458    } else {
459        println!(
460            "Submitted ticket seq={} for memory {} (request {})",
461            cached.seq_no, args.memory_id, request_id
462        );
463    }
464    Ok(())
465}