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;
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    })
199}
200
201pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
202    let mem = open_read_only_mem(&args.file)?;
203    let ticket = mem.current_ticket();
204    if args.json {
205        println!(
206            "{}",
207            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
208        );
209    } else {
210        println!("Ticket issuer: {}", ticket.issuer);
211        println!("Sequence: {}", ticket.seq_no);
212        println!("Expires in (secs): {}", ticket.expires_in_secs);
213        if ticket.capacity_bytes != 0 {
214            println!("Capacity bytes: {}", ticket.capacity_bytes);
215        } else {
216            println!("Capacity bytes: (tier default)");
217        }
218    }
219    Ok(())
220}
221
222pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
223    let mut mem = Memvid::open(&args.file)?;
224    apply_lock_cli(&mut mem, &args.lock);
225    let mut ticket = Ticket::new(&args.issuer, args.seq);
226    if let Some(expires) = args.expires_in {
227        ticket = ticket.expires_in_secs(expires);
228    }
229    if let Some(capacity) = args.capacity {
230        ticket = ticket.capacity_bytes(capacity);
231    }
232    apply_ticket_with_warning(&mut mem, ticket)?;
233    let ticket = mem.current_ticket();
234    if args.json {
235        println!(
236            "{}",
237            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
238        );
239    } else {
240        println!(
241            "Applied ticket seq={} issuer={}",
242            ticket.seq_no, ticket.issuer
243        );
244    }
245    Ok(())
246}
247
248pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
249    let mut mem = Memvid::open(&args.file)?;
250    apply_lock_cli(&mut mem, &args.lock);
251    let current = mem.current_ticket();
252    let next_seq = current.seq_no.saturating_add(1).max(1);
253    let ticket = Ticket::new("", next_seq);
254    apply_ticket_with_warning(&mut mem, ticket)?;
255    let ticket = mem.current_ticket();
256    if args.json {
257        println!(
258            "{}",
259            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
260        );
261    } else {
262        println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
263    }
264    Ok(())
265}
266
267pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
268    let pubkey = ticket_public_key(config)?;
269    let response = api_fetch_ticket(config, &args.memory_id)?;
270
271    // Get file info for registration
272    let file_path = std::fs::canonicalize(&args.file)
273        .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
274    let file_metadata = std::fs::metadata(&args.file)
275        .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
276    let file_size = file_metadata.len() as i64;
277    let file_name = args
278        .file
279        .file_name()
280        .and_then(|n| n.to_str())
281        .unwrap_or("unknown.mv2");
282    let machine_id = hostname::get()
283        .map(|h| h.to_string_lossy().to_string())
284        .unwrap_or_else(|_| "unknown".to_string());
285    let signature_bytes = BASE64_STANDARD
286        .decode(response.payload.signature.as_bytes())
287        .context("ticket signature is not valid base64")?;
288
289    // Extract values from response
290    let issuer = response.payload.issuer.clone();
291    let seq_no = response.payload.sequence;
292    let expires_in = response.payload.expires_in;
293    let capacity_bytes = response.payload.capacity_bytes;
294
295    verify_ticket_signature(
296        pubkey,
297        &args.memory_id,
298        &issuer,
299        seq_no,
300        expires_in,
301        capacity_bytes,
302        &signature_bytes,
303    )
304    .context("ticket signature verification failed")?;
305
306    let mut mem = Memvid::open(&args.file)?;
307    apply_lock_cli(&mut mem, &args.lock);
308
309    // Check if the file already has this ticket or a newer one
310    let current = mem.current_ticket();
311    if current.seq_no >= seq_no {
312        // Already bound with same or newer ticket
313        let capacity = mem.get_capacity();
314
315        // Still register/update file with the dashboard
316        let file_path_str = file_path.to_string_lossy();
317        let register_request = crate::api::RegisterFileRequest {
318            file_name,
319            file_path: &file_path_str,
320            file_size,
321            machine_id: &machine_id,
322        };
323        if let Some(api_key) = config.api_key.as_deref() {
324            if let Err(e) = crate::api::register_file(config, &args.memory_id, &register_request, api_key) {
325                log::warn!("Failed to register file with dashboard: {}", e);
326            }
327        }
328
329        if args.json {
330            let json = json!({
331                "issuer": current.issuer,
332                "seq_no": current.seq_no,
333                "expires_in": current.expires_in_secs,
334                "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
335                "memory_id": args.memory_id,
336                "already_bound": true,
337            });
338            println!("{}", serde_json::to_string_pretty(&json)?);
339        } else {
340            println!(
341                "Already bound to memory {} (seq={}, issuer={})",
342                args.memory_id, current.seq_no, current.issuer
343            );
344            println!(
345                "Current capacity: {:.2} GB",
346                capacity as f64 / 1024.0 / 1024.0 / 1024.0
347            );
348        }
349        return Ok(());
350    }
351
352    // Register file with the dashboard FIRST (before binding locally)
353    // This ensures only one file can be bound to each memory
354    let file_path_str = file_path.to_string_lossy();
355    let register_request = crate::api::RegisterFileRequest {
356        file_name,
357        file_path: &file_path_str,
358        file_size,
359        machine_id: &machine_id,
360    };
361    if let Some(api_key) = config.api_key.as_deref() {
362        crate::api::register_file(config, &args.memory_id, &register_request, api_key)
363            .context("failed to register file with dashboard - this memory may already be bound to another file")?;
364    } else {
365        bail!("API key required for binding. Set MEMVID_API_KEY.");
366    }
367
368    // Create ticket
369    let mut ticket = Ticket::new(&issuer, seq_no).expires_in_secs(expires_in);
370    if let Some(capacity) = capacity_bytes {
371        ticket = ticket.capacity_bytes(capacity);
372    }
373
374    // Create memory binding
375    let binding = MemoryBinding {
376        memory_id: *args.memory_id,
377        memory_name: issuer.clone(), // Use issuer as name for now
378        bound_at: Utc::now(),
379        api_url: config.api_url.clone(),
380    };
381
382    // Use bind_memory to store both ticket and binding
383    mem.bind_memory(binding, ticket)
384        .context("failed to bind memory")?;
385    mem.commit()?;
386
387    let cache_entry = CachedTicket {
388        memory_id: *args.memory_id,
389        issuer: issuer.clone(),
390        seq_no,
391        expires_in,
392        capacity_bytes,
393        signature: response.payload.signature.clone(),
394    };
395    store_cached_ticket(config, &cache_entry)?;
396
397    let capacity = mem.get_capacity();
398
399    if args.json {
400        let json = json!({
401            "issuer": issuer,
402            "seq_no": seq_no,
403            "expires_in": expires_in,
404            "capacity_bytes": capacity_bytes,
405            "memory_id": args.memory_id,
406            "request_id": response.request_id,
407        });
408        println!("{}", serde_json::to_string_pretty(&json)?);
409    } else {
410        println!(
411            "Bound to memory {} (seq={}, issuer={})",
412            args.memory_id, seq_no, issuer
413        );
414        println!(
415            "New capacity: {:.2} GB",
416            capacity as f64 / 1024.0 / 1024.0 / 1024.0
417        );
418    }
419    Ok(())
420}
421
422pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
423    if !args.from_api {
424        bail!("use --from-api to submit the ticket fetched from the API");
425    }
426    let cached = load_cached_ticket(config, &args.memory_id)
427        .context("no cached ticket available; run `tickets sync` first")?;
428    let request = ApiApplyTicketRequest {
429        issuer: &cached.issuer,
430        seq_no: cached.seq_no,
431        expires_in: cached.expires_in,
432        capacity_bytes: cached.capacity_bytes,
433        signature: &cached.signature,
434    };
435    let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
436    if args.json {
437        let json = json!({
438            "memory_id": args.memory_id,
439            "seq_no": cached.seq_no,
440            "request_id": request_id,
441        });
442        println!("{}", serde_json::to_string_pretty(&json)?);
443    } else {
444        println!(
445            "Submitted ticket seq={} for memory {} (request {})",
446            cached.seq_no, args.memory_id, request_id
447        );
448    }
449    Ok(())
450}