Skip to main content

auths_cli/commands/
namespace.rs

1use std::io::{self, Write};
2
3use anyhow::{Context, Result, anyhow};
4use clap::{Parser, Subcommand};
5
6use crate::commands::executable::ExecutableCommand;
7use crate::config::CliConfig;
8use crate::factories::storage::build_auths_context;
9use auths_crypto::AuthsErrorInfo;
10use auths_infra_http::resolve_verified_platform_context;
11use auths_sdk::keychain::KeyAlias;
12use auths_sdk::namespace_registry::NamespaceVerifierRegistry;
13use auths_sdk::ports::{Ecosystem, PackageName};
14use auths_sdk::signing::StorageSigner;
15use auths_sdk::storage_layout::layout;
16use auths_sdk::workflows::namespace::{
17    DelegateNamespaceCommand, TransferNamespaceCommand, initiate_namespace_claim,
18    parse_claim_response, parse_lookup_response, sign_namespace_delegate, sign_namespace_transfer,
19};
20use auths_verifier::CanonicalDid;
21
22/// Manage namespace claims in package ecosystems.
23#[derive(Parser, Debug, Clone)]
24#[command(
25    about = "Manage namespace claims in package ecosystems",
26    after_help = "Examples:
27  auths namespace claim --ecosystem npm --package-name @myname/package
28                        # Claim a namespace in npm registry
29  auths namespace delegate --ecosystem crates.io --package-name mypackage \\
30                           --recipient did:keri:ETarget
31                        # Delegate authority to another identity
32  auths namespace transfer --ecosystem pypi --package-name mypackage \\
33                           --owner did:keri:ENewOwner
34                        # Transfer namespace to a new owner
35
36Related:
37  auths id       — Manage identities
38  auths policy   — Define capability policies
39  auths org      — Manage organization memberships"
40)]
41pub struct NamespaceCommand {
42    #[clap(subcommand)]
43    pub subcommand: NamespaceSubcommand,
44}
45
46/// Subcommands for managing namespace claims and delegations.
47#[derive(Subcommand, Debug, Clone)]
48pub enum NamespaceSubcommand {
49    /// Claim a namespace in a package ecosystem.
50    ///
51    /// Requires a verified platform claim (run `auths id claim github` first).
52    /// The system reads your verified platform identity from the registry —
53    /// no self-asserted usernames.
54    Claim {
55        /// Package ecosystem (e.g. npm, crates.io, pypi)
56        #[arg(long)]
57        ecosystem: String,
58
59        /// Package name to claim
60        #[arg(long)]
61        package_name: String,
62
63        /// Registry URL (defaults to the public registry)
64        #[arg(long)]
65        registry_url: Option<String>,
66
67        /// Alias of the signing key in keychain
68        #[arg(long)]
69        key: Option<String>,
70    },
71
72    /// Delegate namespace authority to another identity
73    Delegate {
74        /// Package ecosystem (e.g. npm, crates.io, pypi)
75        #[arg(long)]
76        ecosystem: String,
77
78        /// Package name
79        #[arg(long)]
80        package_name: String,
81
82        /// DID of the identity to delegate to
83        #[arg(long)]
84        delegate_did: String,
85
86        /// Registry URL (defaults to the public registry)
87        #[arg(long)]
88        registry_url: Option<String>,
89
90        /// Alias of the signing key in keychain
91        #[arg(long)]
92        key: Option<String>,
93    },
94
95    /// Transfer namespace ownership to another identity
96    Transfer {
97        /// Package ecosystem (e.g. npm, crates.io, pypi)
98        #[arg(long)]
99        ecosystem: String,
100
101        /// Package name
102        #[arg(long)]
103        package_name: String,
104
105        /// DID of the new owner
106        #[arg(long)]
107        new_owner_did: String,
108
109        /// Registry URL (defaults to the public registry)
110        #[arg(long)]
111        registry_url: Option<String>,
112
113        /// Alias of the signing key in keychain
114        #[arg(long)]
115        key: Option<String>,
116    },
117
118    /// Look up namespace information
119    Lookup {
120        /// Package ecosystem (e.g. npm, crates.io, pypi)
121        #[arg(long)]
122        ecosystem: String,
123
124        /// Package name
125        #[arg(long)]
126        package_name: String,
127
128        /// Registry URL (defaults to the public registry)
129        #[arg(long)]
130        registry_url: Option<String>,
131    },
132}
133
134impl ExecutableCommand for NamespaceCommand {
135    #[allow(clippy::disallowed_methods)]
136    fn execute(&self, ctx: &CliConfig) -> Result<()> {
137        handle_namespace(self.clone(), ctx)
138    }
139}
140
141/// The registry to publish namespace claims to. There is no default — see
142/// `verify_helpers::require_registry`.
143fn resolve_registry_url(registry_url: Option<String>) -> Result<String> {
144    crate::commands::verify_helpers::require_registry(registry_url)
145}
146
147fn load_identity_and_alias(
148    ctx: &CliConfig,
149    key: Option<String>,
150) -> Result<(
151    auths_verifier::types::IdentityDID,
152    KeyAlias,
153    auths_sdk::context::AuthsContext,
154)> {
155    let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
156    let auths_ctx = build_auths_context(
157        &repo_path,
158        &ctx.env_config,
159        Some(ctx.passphrase_provider.clone()),
160    )?;
161    let managed_identity = auths_ctx
162        .identity_storage
163        .load_identity()
164        .context("Failed to load identity. Run `auths init` first.")?;
165
166    let controller_did = managed_identity.controller_did;
167
168    let alias_str = key.unwrap_or_else(|| {
169        let prefix = controller_did
170            .as_str()
171            .strip_prefix("did:keri:")
172            .unwrap_or(controller_did.as_str());
173        format!(
174            "ns-{}",
175            prefix
176                .chars()
177                .filter(|c| c.is_alphanumeric())
178                .take(20)
179                .collect::<String>()
180                .to_lowercase()
181        )
182    });
183
184    let key_alias = KeyAlias::new_unchecked(alias_str);
185    Ok((controller_did, key_alias, auths_ctx))
186}
187
188fn post_signed_entry(registry_url: &str, body: serde_json::Value) -> Result<serde_json::Value> {
189    let url = format!("{}/v1/log/entries", registry_url.trim_end_matches('/'));
190
191    let client = reqwest::blocking::Client::new();
192    let response = client
193        .post(&url)
194        .json(&body)
195        .send()
196        .with_context(|| format!("Failed to POST to {url}"))?;
197
198    let status = response.status();
199    let response_text = response
200        .text()
201        .context("Failed to read registry response")?;
202
203    if !status.is_success() {
204        return Err(anyhow!(
205            "Registry returned HTTP {}: {}",
206            status,
207            response_text
208        ));
209    }
210
211    serde_json::from_str(&response_text).context("Failed to parse registry response")
212}
213
214/// Handles `namespace` commands for managing package namespace claims.
215#[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() injected here
216pub fn handle_namespace(cmd: NamespaceCommand, ctx: &CliConfig) -> Result<()> {
217    match cmd.subcommand {
218        NamespaceSubcommand::Claim {
219            ecosystem,
220            package_name,
221            registry_url,
222            key,
223        } => {
224            let registry_url = resolve_registry_url(registry_url)?;
225            let (controller_did, key_alias, auths_ctx) = load_identity_and_alias(ctx, key)?;
226            let signer = StorageSigner::new(std::sync::Arc::clone(&auths_ctx.key_storage));
227            let passphrase_provider = ctx.passphrase_provider.clone();
228
229            let eco = Ecosystem::parse(&ecosystem).context("Failed to parse ecosystem")?;
230            let pkg = PackageName::parse(&package_name).context("Failed to parse package name")?;
231
232            #[allow(clippy::disallowed_methods)]
233            // INVARIANT: controller_did from storage is always valid
234            let canonical_did = CanonicalDid::new_unchecked(controller_did.as_str());
235
236            // Fetch verified platform claims from the registry — no self-asserted usernames.
237            let rt_prefetch =
238                tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
239            let platform = rt_prefetch
240                .block_on(resolve_verified_platform_context(
241                    &registry_url,
242                    controller_did.as_str(),
243                ))
244                .map_err(|e| anyhow!("{}", e))?;
245
246            let registry = NamespaceVerifierRegistry::with_defaults();
247            let verifier = registry
248                .require(eco)
249                .context("No verifier available for this ecosystem")?;
250
251            println!("Verifying ownership of {}/{}...\n", eco, package_name);
252
253            let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
254
255            let mut session = rt
256                .block_on(initiate_namespace_claim(
257                    chrono::Utc::now(),
258                    verifier.as_ref(),
259                    eco,
260                    pkg,
261                    canonical_did,
262                    platform,
263                ))
264                .context("Failed to initiate namespace verification")?;
265
266            println!("  {}\n", session.challenge.instructions);
267
268            // Try verification — on OwnershipNotConfirmed, progressively
269            // prompt for additional credentials (e.g. PyPI username)
270            let max_retries = 3;
271            let mut result = None;
272
273            for attempt in 0..max_retries {
274                match rt.block_on(session.complete_ref(
275                    chrono::Utc::now(),
276                    verifier.as_ref(),
277                    &signer,
278                    passphrase_provider.as_ref(),
279                    &key_alias,
280                )) {
281                    Ok(r) => {
282                        result = Some(r);
283                        break;
284                    }
285                    Err(auths_sdk::workflows::namespace::NamespaceError::VerificationFailed(
286                        ref verify_err,
287                    )) => {
288                        use auths_sdk::ports::NamespaceVerifyError;
289                        match verify_err {
290                            NamespaceVerifyError::OwnershipNotConfirmed { ecosystem, .. }
291                                if attempt + 1 < max_retries =>
292                            {
293                                // Progressive prompting: ask for ecosystem-specific username
294                                match *ecosystem {
295                                    Ecosystem::Pypi if session.platform.pypi_username.is_none() => {
296                                        eprintln!(
297                                            "\nAutomatic verification didn't match. \
298                                             Let's try your PyPI username."
299                                        );
300                                        print!("What's your PyPI username? ");
301                                        io::stdout().flush().ok();
302                                        let mut username = String::new();
303                                        let _ = io::stdin().read_line(&mut username);
304                                        let username = username.trim().to_string();
305                                        if !username.is_empty() {
306                                            session.platform.pypi_username = Some(username);
307                                            eprintln!("Retrying with PyPI username...\n");
308                                        }
309                                        continue;
310                                    }
311                                    Ecosystem::Npm if session.platform.npm_username.is_none() => {
312                                        eprintln!(
313                                            "\nAutomatic verification didn't match. \
314                                             Let's try your npm username."
315                                        );
316                                        print!("What's your npm username? ");
317                                        io::stdout().flush().ok();
318                                        let mut username = String::new();
319                                        let _ = io::stdin().read_line(&mut username);
320                                        let username = username.trim().to_string();
321                                        if !username.is_empty() {
322                                            session.platform.npm_username = Some(username);
323                                            eprintln!("Retrying with npm username...\n");
324                                        }
325                                        continue;
326                                    }
327                                    _ => {
328                                        eprintln!(
329                                            "\nVerification not confirmed. Did you complete the step above?"
330                                        );
331                                        print!("Press Enter to retry, or Ctrl+C to cancel...");
332                                        io::stdout().flush().ok();
333                                        let _ = io::stdin().read_line(&mut String::new());
334                                        continue;
335                                    }
336                                }
337                            }
338                            _ => {
339                                eprintln!("\n✗ Verification failed [{}]", verify_err.error_code());
340                                eprintln!("  {verify_err}");
341                                if let Some(hint) = verify_err.suggestion() {
342                                    eprintln!("\n  Hint: {hint}");
343                                }
344                                return Err(anyhow!("{}", verify_err));
345                            }
346                        }
347                    }
348                    Err(e) => return Err(e).context("Namespace verification failed"),
349                }
350            }
351
352            let result = result
353                .ok_or_else(|| anyhow!("Verification failed after {max_retries} attempts"))?;
354
355            println!("\nChecking... ✓ Verified!\n");
356            println!("Claiming namespace {}/{}...", eco, package_name);
357
358            let response = post_signed_entry(&registry_url, result.signed_entry.to_request_body())?;
359
360            let claim_result = parse_claim_response(
361                eco.as_str(),
362                &package_name,
363                controller_did.as_str(),
364                &response,
365            );
366
367            println!("\n✓ Namespace {}/{} claimed", eco, package_name);
368            println!("  Log sequence: {}", claim_result.log_sequence);
369
370            Ok(())
371        }
372
373        NamespaceSubcommand::Delegate {
374            ecosystem,
375            package_name,
376            delegate_did,
377            registry_url,
378            key,
379        } => {
380            let registry_url = resolve_registry_url(registry_url)?;
381            let (controller_did, key_alias, auths_ctx) = load_identity_and_alias(ctx, key)?;
382            let signer = StorageSigner::new(std::sync::Arc::clone(&auths_ctx.key_storage));
383            let passphrase_provider = ctx.passphrase_provider.clone();
384
385            println!(
386                "Delegating namespace {}/{} to {}...",
387                ecosystem, package_name, delegate_did
388            );
389
390            let sdk_cmd = DelegateNamespaceCommand {
391                ecosystem: ecosystem.clone(),
392                package_name: package_name.clone(),
393                delegate_did: delegate_did.clone(),
394                registry_url: registry_url.clone(),
395            };
396
397            let signed = sign_namespace_delegate(
398                &sdk_cmd,
399                &controller_did,
400                &signer,
401                passphrase_provider.as_ref(),
402                &key_alias,
403            )
404            .context("Failed to sign namespace delegation")?;
405
406            post_signed_entry(&registry_url, signed.to_request_body())?;
407
408            println!("\nNamespace delegation successful!");
409            println!("   Ecosystem:  {}", ecosystem);
410            println!("   Package:    {}", package_name);
411            println!("   Delegate:   {}", delegate_did);
412
413            Ok(())
414        }
415
416        NamespaceSubcommand::Transfer {
417            ecosystem,
418            package_name,
419            new_owner_did,
420            registry_url,
421            key,
422        } => {
423            let registry_url = resolve_registry_url(registry_url)?;
424            let (controller_did, key_alias, auths_ctx) = load_identity_and_alias(ctx, key)?;
425            let signer = StorageSigner::new(std::sync::Arc::clone(&auths_ctx.key_storage));
426            let passphrase_provider = ctx.passphrase_provider.clone();
427
428            println!(
429                "Transferring namespace {}/{} to {}...",
430                ecosystem, package_name, new_owner_did
431            );
432
433            let sdk_cmd = TransferNamespaceCommand {
434                ecosystem: ecosystem.clone(),
435                package_name: package_name.clone(),
436                new_owner_did: new_owner_did.clone(),
437                registry_url: registry_url.clone(),
438            };
439
440            let signed = sign_namespace_transfer(
441                &sdk_cmd,
442                &controller_did,
443                &signer,
444                passphrase_provider.as_ref(),
445                &key_alias,
446            )
447            .context("Failed to sign namespace transfer")?;
448
449            post_signed_entry(&registry_url, signed.to_request_body())?;
450
451            println!("\nNamespace transfer successful!");
452            println!("   Ecosystem:  {}", ecosystem);
453            println!("   Package:    {}", package_name);
454            println!("   New Owner:  {}", new_owner_did);
455
456            Ok(())
457        }
458
459        NamespaceSubcommand::Lookup {
460            ecosystem,
461            package_name,
462            registry_url,
463        } => {
464            let registry_url = resolve_registry_url(registry_url)?;
465
466            println!("Looking up namespace {}/{}...", ecosystem, package_name);
467
468            let url = format!(
469                "{}/v1/namespaces/{}/{}",
470                registry_url.trim_end_matches('/'),
471                ecosystem,
472                package_name
473            );
474
475            let client = reqwest::blocking::Client::new();
476            let response = client
477                .get(&url)
478                .send()
479                .with_context(|| format!("Failed to GET {url}"))?;
480
481            if response.status() == reqwest::StatusCode::NOT_FOUND {
482                println!("\nNamespace {}/{} is not claimed.", ecosystem, package_name);
483                return Ok(());
484            }
485
486            if !response.status().is_success() {
487                let status = response.status();
488                let body = response.text().unwrap_or_default();
489                return Err(anyhow!("Registry returned HTTP {}: {}", status, body));
490            }
491
492            let body: serde_json::Value = response
493                .json()
494                .context("Failed to parse registry response")?;
495
496            let info = parse_lookup_response(&ecosystem, &package_name, &body);
497
498            println!("\nNamespace: {}/{}", info.ecosystem, info.package_name);
499            println!("   Owner: {}", info.owner_did);
500            if info.delegates.is_empty() {
501                println!("   Delegates: (none)");
502            } else {
503                println!("   Delegates:");
504                for d in &info.delegates {
505                    println!("     - {}", d);
506                }
507            }
508
509            Ok(())
510        }
511    }
512}