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