Skip to main content

actr_cli/commands/
registry.rs

1//! `actr registry` — remote service registry interactions (AIS / signaling).
2//!
3//! Subcommands:
4//!   - `discover`    — find services available on the network
5//!   - `publish`     — push a signed `.actr` package to the MFR registry
6//!   - `fingerprint` — compute / verify / lock service semantic fingerprints
7
8use std::path::PathBuf;
9
10use anyhow::{Context, Result};
11use async_trait::async_trait;
12use base64::Engine;
13use clap::{Args, Subcommand};
14use serde::Serialize;
15
16use super::discovery::DiscoveryCommand;
17use super::fingerprint::FingerprintCommand;
18use crate::core::{Command, CommandContext, CommandResult, ComponentType};
19
20#[derive(Args, Debug)]
21pub struct RegistryArgs {
22    #[command(subcommand)]
23    pub command: RegistryCommand,
24}
25
26#[derive(Subcommand, Debug)]
27pub enum RegistryCommand {
28    /// Discover available Actor services on the network
29    Discover(DiscoveryCommand),
30    /// Publish a signed .actr package to the MFR registry
31    Publish(RegistryPublishArgs),
32    /// Compute / verify / lock service semantic fingerprints
33    Fingerprint(FingerprintCommand),
34}
35
36#[async_trait]
37impl Command for RegistryArgs {
38    async fn execute(&self, ctx: &CommandContext) -> Result<CommandResult> {
39        match &self.command {
40            RegistryCommand::Discover(cmd) => {
41                let command = DiscoveryCommand::from_args(cmd);
42                {
43                    let container = ctx.container.lock().unwrap();
44                    container.validate(&command.required_components())?;
45                }
46                command.execute(ctx).await
47            }
48            RegistryCommand::Fingerprint(cmd) => cmd.execute(ctx).await,
49            RegistryCommand::Publish(args) => {
50                execute_publish(args).await?;
51                Ok(CommandResult::Success(String::new()))
52            }
53        }
54    }
55
56    fn required_components(&self) -> Vec<ComponentType> {
57        match &self.command {
58            RegistryCommand::Discover(cmd) => {
59                DiscoveryCommand::from_args(cmd).required_components()
60            }
61            RegistryCommand::Fingerprint(_) | RegistryCommand::Publish(_) => vec![],
62        }
63    }
64
65    fn name(&self) -> &str {
66        "registry"
67    }
68
69    fn description(&self) -> &str {
70        "Interact with the remote service registry (discover, publish, fingerprint)"
71    }
72}
73
74// ── publish ──────────────────────────────────────────────────────────────────
75
76#[derive(Args, Debug)]
77pub struct RegistryPublishArgs {
78    /// .actr package file to publish
79    #[arg(long, short = 'p', value_name = "FILE")]
80    pub package: PathBuf,
81
82    /// Path to MFR keychain JSON file (used to verify publisher identity)
83    #[arg(long, short = 'k', value_name = "FILE")]
84    pub keychain: PathBuf,
85
86    /// MFR registry endpoint URL (e.g. http://localhost:8081)
87    #[arg(long, short = 'e', value_name = "URL")]
88    pub endpoint: String,
89}
90
91#[derive(Serialize)]
92struct SignablePublishBody<'a> {
93    manufacturer: &'a str,
94    name: &'a str,
95    version: &'a str,
96    target: &'a str,
97    manifest: &'a str,
98    signature: &'a str,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    proto_files: Option<&'a serde_json::Value>,
101    nonce: &'a str,
102}
103
104#[derive(Serialize)]
105struct FinalPublishBody<'a> {
106    manufacturer: &'a str,
107    name: &'a str,
108    version: &'a str,
109    target: &'a str,
110    manifest: &'a str,
111    signature: &'a str,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    proto_files: Option<&'a serde_json::Value>,
114    nonce: &'a str,
115    nonce_sig: &'a str,
116}
117
118async fn execute_publish(args: &RegistryPublishArgs) -> Result<()> {
119    tracing::debug!("reading .actr package: {:?}", args.package);
120    let package_bytes = std::fs::read(&args.package)
121        .with_context(|| format!("Failed to read package: {}", args.package.display()))?;
122
123    let manifest_str = actr_pack::read_manifest_raw(&package_bytes)
124        .with_context(|| "Failed to read manifest from .actr package")?;
125    let manifest = actr_pack::PackageManifest::from_toml(&manifest_str)
126        .with_context(|| "Failed to parse manifest TOML")?;
127    let sig_raw = actr_pack::read_signature(&package_bytes)
128        .with_context(|| "Failed to read manifest.sig from .actr package")?;
129
130    tracing::debug!(
131        "loading keychain for identity verification: {:?}",
132        args.keychain
133    );
134    let keychain_content = std::fs::read_to_string(&args.keychain)
135        .with_context(|| format!("Failed to read keychain: {}", args.keychain.display()))?;
136    let keychain: serde_json::Value =
137        serde_json::from_str(&keychain_content).with_context(|| "Invalid keychain JSON")?;
138
139    let kc_privkey_b64 = keychain["private_key"]
140        .as_str()
141        .ok_or_else(|| anyhow::anyhow!("Keychain missing 'private_key' field"))?;
142    let kc_privkey_bytes = base64::engine::general_purpose::STANDARD
143        .decode(kc_privkey_b64)
144        .with_context(|| "Invalid private key in keychain")?;
145    let kc_arr: [u8; 32] = kc_privkey_bytes
146        .try_into()
147        .map_err(|_| anyhow::anyhow!("Private key must be 32 bytes"))?;
148    let kc_signing_key = ed25519_dalek::SigningKey::from_bytes(&kc_arr);
149    let kc_key_id = actr_pack::compute_key_id(&kc_signing_key.verifying_key().to_bytes());
150
151    match manifest.signing_key_id {
152        Some(ref manifest_key_id) if manifest_key_id == &kc_key_id => {}
153        Some(ref manifest_key_id) => {
154            anyhow::bail!(
155                "Key mismatch: package was built with '{}' but keychain key is '{}'. \
156                 Rebuild with the correct MFR key, or use the matching keychain.",
157                manifest_key_id,
158                kc_key_id
159            );
160        }
161        None => {
162            anyhow::bail!(
163                "Package manifest has no 'signing_key_id'. \
164                 Rebuild with the latest 'actr build' to embed a signing_key_id."
165            );
166        }
167    }
168
169    let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_raw);
170
171    println!("📦 Publishing package: {}", manifest.actr_type_str());
172    println!("   manufacturer: {}", manifest.manufacturer);
173    println!("   name:         {}", manifest.name);
174    println!("   version:      {}", manifest.version);
175    println!("   target:       {}", manifest.binary.target);
176    println!("   signing_key:  {}", kc_key_id);
177    println!("✅ Identity verified (keychain matches package)");
178    println!("🔐 Forwarding original signature (no re-signing)");
179
180    let proto_files = actr_pack::read_proto_files(&package_bytes).unwrap_or_default();
181    let proto_filing = if !proto_files.is_empty() {
182        let proto_entries: Vec<serde_json::Value> = proto_files
183            .iter()
184            .map(|(name, content)| {
185                serde_json::json!({
186                    "name": name,
187                    "content": String::from_utf8_lossy(content),
188                })
189            })
190            .collect();
191        println!("📋 Proto files for filing: {} file(s)", proto_entries.len());
192        Some(serde_json::json!({ "protobufs": proto_entries }))
193    } else {
194        None
195    };
196
197    let endpoint = args
198        .endpoint
199        .trim_end_matches("/ais")
200        .trim_end_matches('/')
201        .to_string();
202
203    let base_url = endpoint.trim_end_matches('/');
204    let nonce_url = format!("{}/mfr/pkg/nonce", base_url);
205    let client = reqwest::Client::new();
206
207    println!("🔑 Requesting publish nonce...");
208    let nonce_resp = client
209        .post(&nonce_url)
210        .json(&serde_json::json!({ "manufacturer": manifest.manufacturer }))
211        .send()
212        .await
213        .with_context(|| format!("Failed to request nonce from: {}", nonce_url))?;
214
215    if !nonce_resp.status().is_success() {
216        let status = nonce_resp.status();
217        let body = nonce_resp.text().await.unwrap_or_default();
218        anyhow::bail!("Nonce request failed (HTTP {}): {}", status, body);
219    }
220
221    let nonce_json: serde_json::Value = nonce_resp
222        .json()
223        .await
224        .with_context(|| "Failed to parse nonce response")?;
225    let nonce_b64 = nonce_json["nonce"]
226        .as_str()
227        .ok_or_else(|| anyhow::anyhow!("Nonce response missing 'nonce' field"))?
228        .to_string();
229
230    let nonce_bytes = base64::engine::general_purpose::STANDARD
231        .decode(&nonce_b64)
232        .with_context(|| "Invalid nonce base64 from server")?;
233
234    let signable_body = SignablePublishBody {
235        manufacturer: &manifest.manufacturer,
236        name: &manifest.name,
237        version: &manifest.version,
238        target: &manifest.binary.target,
239        manifest: &manifest_str,
240        signature: &sig_b64,
241        proto_files: proto_filing.as_ref(),
242        nonce: &nonce_b64,
243    };
244    let signable_body_bytes = serde_json::to_vec(&signable_body)
245        .with_context(|| "Failed to serialize signable publish body")?;
246
247    let nonce_sig_b64 = {
248        use ed25519_dalek::Signer;
249        use sha2::{Digest, Sha256};
250
251        let body_hash = hex::encode(Sha256::digest(&signable_body_bytes));
252        let nonce_hex = hex::encode(&nonce_bytes);
253        let payload = format!(
254            "ACTR-PUBLISH-V1\nmanufacturer={}\nmethod=POST\npath=/mfr/pkg/publish\nnonce={}\nbody_sha256={}",
255            manifest.manufacturer, nonce_hex, body_hash
256        );
257        let sig = kc_signing_key.sign(payload.as_bytes());
258        base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())
259    };
260    println!("✅ Nonce signed (challenge-response)");
261
262    let publish_url = format!("{}/mfr/pkg/publish", base_url);
263    println!("📡 Publishing to: {}", publish_url);
264
265    let publish_body_bytes = serde_json::to_vec(&FinalPublishBody {
266        manufacturer: &manifest.manufacturer,
267        name: &manifest.name,
268        version: &manifest.version,
269        target: &manifest.binary.target,
270        manifest: &manifest_str,
271        signature: &sig_b64,
272        proto_files: proto_filing.as_ref(),
273        nonce: &nonce_b64,
274        nonce_sig: &nonce_sig_b64,
275    })
276    .with_context(|| "Failed to serialize publish request body")?;
277
278    let resp = client
279        .post(&publish_url)
280        .header(reqwest::header::CONTENT_TYPE, "application/json")
281        .body(publish_body_bytes)
282        .send()
283        .await
284        .with_context(|| format!("Failed to connect to MFR endpoint: {}", publish_url))?;
285
286    let status = resp.status();
287    let body = resp.text().await.unwrap_or_default();
288
289    if !status.is_success() {
290        eprintln!("❌ Publish failed (HTTP {})", status);
291        eprintln!("   Response: {}", body);
292        anyhow::bail!("MFR publish failed with status {}: {}", status, body);
293    }
294
295    if let Ok(result) = serde_json::from_str::<serde_json::Value>(&body) {
296        let type_str = result["type_str"].as_str().unwrap_or("unknown");
297        let pkg_id = result["id"].as_i64().unwrap_or(0);
298        println!();
299        println!("✅ Package published successfully!");
300        println!("   type_str:  {}", type_str);
301        println!("   pkg_id:    {}", pkg_id);
302        println!("   status:    active");
303    } else {
304        println!();
305        println!("✅ Package published successfully!");
306        println!("   Response: {}", body);
307    }
308
309    Ok(())
310}