memvid_cli/commands/
tickets.rs

1//! Ticket management command handlers (list, issue, revoke, sync, apply)
2
3use std::path::PathBuf;
4
5use anyhow::{anyhow, bail, Context, Result};
6use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
7use chrono::Utc;
8use clap::{ArgAction, Args, Subcommand};
9use memvid_core::types::MemoryBinding;
10use memvid_core::{verify_ticket_signature, Memvid, Ticket};
11use serde_json::json;
12use uuid::Uuid;
13
14use crate::api::{
15    apply_ticket as api_apply_ticket, fetch_ticket as api_fetch_ticket,
16    ApplyTicketRequest as ApiApplyTicketRequest,
17};
18use crate::commands::{apply_ticket_with_warning, LockCliArgs};
19use crate::config::CliConfig;
20use crate::ticket_cache::{load as load_cached_ticket, store as store_cached_ticket, CachedTicket};
21use crate::utils::{apply_lock_cli, open_read_only_mem};
22
23/// Subcommands for the `tickets` command
24#[derive(Subcommand)]
25pub enum TicketsCommand {
26    /// Show the currently applied ticket
27    List(TicketsStatusArgs),
28    /// Apply a new ticket
29    Issue(TicketsIssueArgs),
30    /// Clear ticket metadata while advancing the sequence
31    Revoke(TicketsRevokeArgs),
32    /// Synchronise the ticket state with the Memvid API
33    Sync(TicketsSyncArgs),
34    /// Submit a ticket fetched from the API back to the control plane
35    Apply(TicketsApplyArgs),
36}
37
38/// Arguments for the `tickets` command
39#[derive(Args)]
40pub struct TicketsArgs {
41    #[command(subcommand)]
42    pub command: TicketsCommand,
43}
44
45/// Arguments for the `tickets sync` subcommand
46#[derive(Args)]
47pub struct TicketsSyncArgs {
48    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
49    pub file: PathBuf,
50    #[arg(long = "memory-id", value_name = "UUID", value_parser = clap::value_parser!(Uuid))]
51    pub memory_id: Uuid,
52    #[arg(long)]
53    pub json: bool,
54
55    #[command(flatten)]
56    pub lock: LockCliArgs,
57}
58
59/// Arguments for the `tickets apply` subcommand
60#[derive(Args)]
61pub struct TicketsApplyArgs {
62    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
63    pub file: PathBuf,
64    #[arg(long = "memory-id", value_name = "UUID", value_parser = clap::value_parser!(Uuid))]
65    pub memory_id: Uuid,
66    #[arg(long = "from-api", action = ArgAction::SetTrue)]
67    pub from_api: bool,
68    #[arg(long)]
69    pub json: bool,
70}
71
72/// Arguments for the `tickets issue` subcommand
73#[derive(Args)]
74pub struct TicketsIssueArgs {
75    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
76    pub file: PathBuf,
77    #[arg(long)]
78    pub issuer: String,
79    #[arg(long, value_name = "SEQ")]
80    pub seq: i64,
81    #[arg(long = "expires-in", value_name = "SECS")]
82    pub expires_in: Option<u64>,
83    #[arg(long, value_name = "BYTES")]
84    pub capacity: Option<u64>,
85    #[arg(long)]
86    pub json: bool,
87
88    #[command(flatten)]
89    pub lock: LockCliArgs,
90}
91
92/// Arguments for the `tickets revoke` subcommand
93#[derive(Args)]
94pub struct TicketsRevokeArgs {
95    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
96    pub file: PathBuf,
97    #[arg(long)]
98    pub json: bool,
99
100    #[command(flatten)]
101    pub lock: LockCliArgs,
102}
103
104/// Arguments for the `tickets list` subcommand
105#[derive(Args)]
106pub struct TicketsStatusArgs {
107    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
108    pub file: PathBuf,
109    #[arg(long)]
110    pub json: bool,
111}
112
113// ============================================================================
114// Tickets command handlers
115// ============================================================================
116
117pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
118    match args.command {
119        TicketsCommand::List(status) => handle_ticket_status(status),
120        TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
121        TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
122        TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
123        TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
124    }
125}
126
127fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
128    config
129        .ticket_pubkey
130        .as_ref()
131        .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
132}
133
134fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
135    json!({
136        "issuer": ticket.issuer,
137        "seq_no": ticket.seq_no,
138        "expires_in_secs": ticket.expires_in_secs,
139        "capacity_bytes": ticket.capacity_bytes,
140    })
141}
142
143pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
144    let mem = open_read_only_mem(&args.file)?;
145    let ticket = mem.current_ticket();
146    if args.json {
147        println!(
148            "{}",
149            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
150        );
151    } else {
152        println!("Ticket issuer: {}", ticket.issuer);
153        println!("Sequence: {}", ticket.seq_no);
154        println!("Expires in (secs): {}", ticket.expires_in_secs);
155        if ticket.capacity_bytes != 0 {
156            println!("Capacity bytes: {}", ticket.capacity_bytes);
157        } else {
158            println!("Capacity bytes: (tier default)");
159        }
160    }
161    Ok(())
162}
163
164pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
165    let mut mem = Memvid::open(&args.file)?;
166    apply_lock_cli(&mut mem, &args.lock);
167    let mut ticket = Ticket::new(&args.issuer, args.seq);
168    if let Some(expires) = args.expires_in {
169        ticket = ticket.expires_in_secs(expires);
170    }
171    if let Some(capacity) = args.capacity {
172        ticket = ticket.capacity_bytes(capacity);
173    }
174    apply_ticket_with_warning(&mut mem, ticket)?;
175    let ticket = mem.current_ticket();
176    if args.json {
177        println!(
178            "{}",
179            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
180        );
181    } else {
182        println!(
183            "Applied ticket seq={} issuer={}",
184            ticket.seq_no, ticket.issuer
185        );
186    }
187    Ok(())
188}
189
190pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
191    let mut mem = Memvid::open(&args.file)?;
192    apply_lock_cli(&mut mem, &args.lock);
193    let current = mem.current_ticket();
194    let next_seq = current.seq_no.saturating_add(1).max(1);
195    let ticket = Ticket::new("", next_seq);
196    apply_ticket_with_warning(&mut mem, ticket)?;
197    let ticket = mem.current_ticket();
198    if args.json {
199        println!(
200            "{}",
201            serde_json::to_string_pretty(&ticket_to_json(&ticket))?
202        );
203    } else {
204        println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
205    }
206    Ok(())
207}
208
209pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
210    let pubkey = ticket_public_key(config)?;
211    let response = api_fetch_ticket(config, &args.memory_id)?;
212
213    // Get file info for registration
214    let file_path = std::fs::canonicalize(&args.file)
215        .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
216    let file_metadata = std::fs::metadata(&args.file)
217        .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
218    let file_size = file_metadata.len() as i64;
219    let file_name = args
220        .file
221        .file_name()
222        .and_then(|n| n.to_str())
223        .unwrap_or("unknown.mv2");
224    let machine_id = hostname::get()
225        .map(|h| h.to_string_lossy().to_string())
226        .unwrap_or_else(|_| "unknown".to_string());
227    let signature_bytes = BASE64_STANDARD
228        .decode(response.payload.signature.as_bytes())
229        .context("ticket signature is not valid base64")?;
230
231    // Extract values from response
232    let issuer = response.payload.issuer.clone();
233    let seq_no = response.payload.sequence;
234    let expires_in = response.payload.expires_in;
235    let capacity_bytes = response.payload.capacity_bytes;
236
237    verify_ticket_signature(
238        pubkey,
239        &args.memory_id,
240        &issuer,
241        seq_no,
242        expires_in,
243        capacity_bytes,
244        &signature_bytes,
245    )
246    .context("ticket signature verification failed")?;
247
248    let mut mem = Memvid::open(&args.file)?;
249    apply_lock_cli(&mut mem, &args.lock);
250
251    // Check if the file already has this ticket or a newer one
252    let current = mem.current_ticket();
253    if current.seq_no >= seq_no {
254        // Already bound with same or newer ticket
255        let capacity = mem.get_capacity();
256
257        // Still register/update file with the control plane
258        let file_path_str = file_path.to_string_lossy();
259        let register_request = crate::api::RegisterFileRequest {
260            file_name,
261            file_path: &file_path_str,
262            file_size,
263            machine_id: &machine_id,
264        };
265        if let Err(e) = crate::api::register_file(config, &args.memory_id, &register_request) {
266            log::warn!("Failed to register file with control plane: {}", e);
267        }
268
269        if args.json {
270            let json = json!({
271                "issuer": current.issuer,
272                "seq_no": current.seq_no,
273                "expires_in": current.expires_in_secs,
274                "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
275                "memory_id": args.memory_id,
276                "already_bound": true,
277            });
278            println!("{}", serde_json::to_string_pretty(&json)?);
279        } else {
280            println!(
281                "Already bound to memory {} (seq={}, issuer={})",
282                args.memory_id, current.seq_no, current.issuer
283            );
284            println!(
285                "Current capacity: {:.2} GB",
286                capacity as f64 / 1024.0 / 1024.0 / 1024.0
287            );
288        }
289        return Ok(());
290    }
291
292    // Create ticket
293    let mut ticket = Ticket::new(&issuer, seq_no).expires_in_secs(expires_in);
294    if let Some(capacity) = capacity_bytes {
295        ticket = ticket.capacity_bytes(capacity);
296    }
297
298    // Create memory binding
299    let binding = MemoryBinding {
300        memory_id: args.memory_id,
301        memory_name: issuer.clone(), // Use issuer as name for now
302        bound_at: Utc::now(),
303        api_url: config.api_url.clone(),
304    };
305
306    // Use bind_memory to store both ticket and binding
307    mem.bind_memory(binding, ticket)
308        .context("failed to bind memory")?;
309    mem.commit()?;
310
311    let cache_entry = CachedTicket {
312        memory_id: args.memory_id,
313        issuer: issuer.clone(),
314        seq_no,
315        expires_in,
316        capacity_bytes,
317        signature: response.payload.signature.clone(),
318    };
319    store_cached_ticket(config, &cache_entry)?;
320
321    let capacity = mem.get_capacity();
322
323    // Register file with the control plane
324    let file_path_str = file_path.to_string_lossy();
325    let register_request = crate::api::RegisterFileRequest {
326        file_name,
327        file_path: &file_path_str,
328        file_size,
329        machine_id: &machine_id,
330    };
331    if let Err(e) = crate::api::register_file(config, &args.memory_id, &register_request) {
332        // Don't fail the sync if file registration fails, just warn
333        log::warn!("Failed to register file with control plane: {}", e);
334    }
335
336    if args.json {
337        let json = json!({
338            "issuer": issuer,
339            "seq_no": seq_no,
340            "expires_in": expires_in,
341            "capacity_bytes": capacity_bytes,
342            "memory_id": args.memory_id,
343            "request_id": response.request_id,
344        });
345        println!("{}", serde_json::to_string_pretty(&json)?);
346    } else {
347        println!(
348            "Bound to memory {} (seq={}, issuer={})",
349            args.memory_id, seq_no, issuer
350        );
351        println!(
352            "New capacity: {:.2} GB",
353            capacity as f64 / 1024.0 / 1024.0 / 1024.0
354        );
355    }
356    Ok(())
357}
358
359pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
360    if !args.from_api {
361        bail!("use --from-api to submit the ticket fetched from the API");
362    }
363    let cached = load_cached_ticket(config, &args.memory_id)
364        .context("no cached ticket available; run `tickets sync` first")?;
365    let request = ApiApplyTicketRequest {
366        issuer: &cached.issuer,
367        seq_no: cached.seq_no,
368        expires_in: cached.expires_in,
369        capacity_bytes: cached.capacity_bytes,
370        signature: &cached.signature,
371    };
372    let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
373    if args.json {
374        let json = json!({
375            "memory_id": args.memory_id,
376            "seq_no": cached.seq_no,
377            "request_id": request_id,
378        });
379        println!("{}", serde_json::to_string_pretty(&json)?);
380    } else {
381        println!(
382            "Submitted ticket seq={} for memory {} (request {})",
383            cached.seq_no, args.memory_id, request_id
384        );
385    }
386    Ok(())
387}