Skip to main content

cairn_cli/cli/
poi.rs

1use clap::{Args, Subcommand};
2use serde_json::json;
3
4use crate::client::BackpacClient;
5use crate::config::CairnConfig;
6use crate::errors::CairnError;
7
8use super::{output_json, Cli};
9
10#[derive(Args, Debug)]
11pub struct PoiArgs {
12    #[command(subcommand)]
13    pub command: PoiCommands,
14}
15
16#[derive(Subcommand, Debug)]
17pub enum PoiCommands {
18    /// Create a Proof of Intent for a pending task.
19    Create {
20        /// Blockchain chain (overrides config)
21        #[arg(long)]
22        chain: Option<String>,
23
24        /// Network (overrides config)
25        #[arg(long)]
26        network: Option<String>,
27
28        /// Parent PoI ID for chained intents
29        #[arg(long)]
30        parent: Option<String>,
31
32        /// Maximum chain depth (default: 5)
33        #[arg(long, default_value = "5")]
34        max_depth: u32,
35
36        /// TTL in seconds before the PoI expires (default: 600)
37        #[arg(long, default_value = "600")]
38        ttl: u64,
39
40        /// Additional metadata as JSON string
41        #[arg(long)]
42        metadata: Option<String>,
43
44        /// Path to the Ed25519 key file for signing (defaults to config or CAIRN_KEY_FILE)
45        #[arg(long)]
46        key_file: Option<String>,
47    },
48
49    /// Retrieve a Proof of Intent by ID.
50    Get {
51        /// PoI ID to retrieve
52        poi_id: String,
53    },
54
55    /// List Proofs of Intent for the authenticated agent.
56    List {
57        /// Filter by specific status
58        #[arg(long)]
59        status: Option<String>,
60
61        /// Filter by ISO timestamp (show intents since this time)
62        #[arg(long)]
63        since: Option<String>,
64
65        /// Maximum results to return (default: 20)
66        #[arg(long, default_value = "20")]
67        limit: u32,
68    },
69}
70
71impl PoiArgs {
72    pub async fn execute(&self, cli: &Cli) -> Result<(), CairnError> {
73        let client = BackpacClient::new(cli.jwt.as_deref(), cli.api_url.as_deref(), cli.worker_url.as_deref());
74        let config = CairnConfig::load();
75
76        match &self.command {
77            PoiCommands::Create {
78                chain: _,
79                network,
80                parent,
81                max_depth,
82                ttl,
83                metadata,
84                key_file,
85            } => {
86                let _n = network.as_deref().or(cli.network.as_deref()).unwrap_or(&config.network);
87
88                // 1. Get Agent Info from JWT
89                let claims = client.claims().ok_or_else(|| {
90                    CairnError::Auth("Not logged in. Run 'cairn auth connect' first.".to_string())
91                })?;
92
93                let agent_id = claims["sub"].as_str().ok_or_else(|| CairnError::Auth("Missing 'sub' in JWT".to_string()))?;
94                let account_id = claims["account_id"].as_str().ok_or_else(|| CairnError::Auth("Missing 'account_id' in JWT".to_string()))?;
95                let claim_chain_raw = claims["chain"].as_str().ok_or_else(|| CairnError::Auth("Missing 'chain' in JWT".to_string()))?;
96
97                // Split chain:network if needed
98                let (auth_chain, auth_network) = if let Some(pos) = claim_chain_raw.find(':') {
99                    (&claim_chain_raw[..pos], Some(&claim_chain_raw[pos+1..]))
100                } else {
101                    (claim_chain_raw, None)
102                };
103
104                let final_chain = auth_chain;
105                let final_network = network.as_deref()
106                    .or(auth_network)
107                    .or(cli.network.as_deref())
108                    .unwrap_or(&config.network);
109
110                // 2. Load Key for Signing
111                let env_key = std::env::var("CAIRN_KEY_FILE").ok();
112                let key_path = key_file
113                    .as_deref()
114                    .or(config.key_file.as_deref())
115                    .or(env_key.as_deref())
116                    .ok_or_else(|| CairnError::InvalidInput("Missing --key-file. Required for PoI signing.".to_string()))?;
117
118                let key_bytes = crate::crypto::read_key_file(key_path)?;
119                // If it's a 64-byte Solana keypair, use the first 32 bytes as secret
120                let secret_bytes = if key_bytes.len() >= 64 { &key_bytes[0..32] } else { &key_bytes };
121
122                // 3. Construct Payload (Matches worker-ts/src/routes/pois.ts)
123                let expires_at = chrono::Utc::now() + chrono::Duration::seconds(*ttl as i64);
124                
125                let poi_data = json!({
126                    "account_id": account_id,
127                    "agent_id": agent_id,
128                    "chain": final_chain,
129                    "network": final_network,
130                    "metadata": metadata.as_ref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({})),
131                    "parent_poi_id": parent.as_deref().map(|p| json!(p)).unwrap_or(serde_json::Value::Null),
132                    "chain_depth": 0, // Initial PoI
133                    "max_chain_depth": max_depth,
134                    "expires_at": expires_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
135                });
136
137                // 4. Sign Payload
138                // Note: The backend uses fast-json-stable-stringify which sorts keys. 
139                // We'll use a BTreeMap to ensure sorted order in Rust before signing.
140                let sorted_payload: std::collections::BTreeMap<String, serde_json::Value> = 
141                    serde_json::from_value(poi_data.clone()).unwrap();
142                
143                let message = serde_json::to_string(&sorted_payload).unwrap();
144                let signature_bytes = crate::crypto::ed25519_sign(message.as_bytes(), secret_bytes)?;
145                let signature_hex = hex::encode(signature_bytes);
146
147                // 5. Submit to Backend
148                let mut body = poi_data;
149                body["poi_signature"] = json!(signature_hex);
150
151                let result = client.post("/v1/pois", &body).await?;
152                output_json(&result, &cli.output);
153                Ok(())
154            }
155
156            PoiCommands::Get { poi_id } => {
157                let path = format!("/v1/pois/{}", poi_id);
158                let result = client.get(&path).await?;
159                output_json(&result, &cli.output);
160                Ok(())
161            }
162
163            PoiCommands::List { status, since, limit } => {
164                let mut query_parts: Vec<String> = Vec::new();
165                if let Some(s) = status {
166                    query_parts.push(format!("status={}", s));
167                }
168                if let Some(s) = since {
169                    query_parts.push(format!("since={}", s));
170                }
171                query_parts.push(format!("limit={}", limit));
172
173                let path = format!("/v1/pois?{}", query_parts.join("&"));
174                let result = client.get(&path).await?;
175                output_json(&result, &cli.output);
176                Ok(())
177            }
178        }
179    }
180}