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