Skip to main content

vta_cli_common/commands/
agent_names.rs

1//! `pnm did-mgmt agent-names …` — bind and inspect agent names.
2//!
3//! An agent name is a human-memorable `domain/@name` that resolves to a DID.
4//! Binding one edits the DID document's `alsoKnownAs` and republishes the
5//! signed log; the hosting server then serves `/@name` as a redirect to the
6//! DID — but *only* for a name the signed document claims, which it re-derives
7//! from the log on every publish. So the claim in the document is not a hint,
8//! it is the authorisation.
9//!
10//! That is one half of the binding. The other half — confirming a name leads
11//! back to the DID that claims it — is what
12//! [`vta_sdk::display_name::agent_name`] does on the reading side, and why
13//! `--resolve-agent-names` exists.
14
15use vta_sdk::prelude::*;
16
17use crate::display::shorten_did;
18use crate::render::{BOLD, CYAN, DIM, GREEN, RESET, YELLOW, is_json_output, print_json};
19
20/// The full name as an operator would type or read it, e.g.
21/// `webvh.storm.ws/@ops`.
22///
23/// Derived from the DID's host rather than asked for, because the domain is
24/// not the operator's to choose — it is wherever the DID is hosted, and the
25/// hosting server keys its index on exactly that.
26fn qualified(did: &str, name: &str) -> String {
27    match domain_of(did) {
28        Some(domain) => format!("{domain}/@{name}"),
29        None => format!("@{name}"),
30    }
31}
32
33/// The host component of a `did:webvh` / `did:web` identifier.
34///
35/// `did:webvh:<scid>:<host>[:<path>…]`, with the host percent-decoded because
36/// a port is encoded (`localhost%3A8534`).
37fn domain_of(did: &str) -> Option<String> {
38    let mut parts = did.split(':');
39    if parts.next()? != "did" {
40        return None;
41    }
42    let method = parts.next()?;
43    if method != "webvh" && method != "web" {
44        return None;
45    }
46    let _scid = parts.next()?;
47    let host = parts.next()?;
48    Some(host.replace("%3A", ":").replace("%3a", ":"))
49}
50
51pub async fn cmd_agent_name_set(
52    client: &VtaClient,
53    did: &str,
54    name: &str,
55) -> Result<(), Box<dyn std::error::Error>> {
56    let result = client.set_agent_name(did, name).await?;
57    if is_json_output() {
58        print_json(&result)?;
59        return Ok(());
60    }
61    println!("{GREEN}\u{2713}{RESET} Agent name bound.");
62    println!(
63        "  {CYAN}Name:{RESET}   {BOLD}{}{RESET}",
64        qualified(did, name)
65    );
66    println!("  {CYAN}DID:{RESET}    {did}");
67    println!(
68        "  {DIM}The DID document now claims this name; the hosting server \
69         serves it as a redirect.{RESET}"
70    );
71    Ok(())
72}
73
74pub async fn cmd_agent_name_remove(
75    client: &VtaClient,
76    did: &str,
77    name: &str,
78) -> Result<(), Box<dyn std::error::Error>> {
79    let result = client.remove_agent_name(did, name).await?;
80    if is_json_output() {
81        print_json(&result)?;
82        return Ok(());
83    }
84    println!(
85        "{GREEN}\u{2713}{RESET} Agent name released: {}",
86        qualified(did, name)
87    );
88    println!(
89        "  {DIM}The claim is gone from the DID document and the name is free \
90         for anyone to claim. Use `disable` instead to keep it reserved.{RESET}"
91    );
92    Ok(())
93}
94
95pub async fn cmd_agent_name_disable(
96    client: &VtaClient,
97    did: &str,
98    name: &str,
99) -> Result<(), Box<dyn std::error::Error>> {
100    let result = client.disable_agent_name(did, name).await?;
101    if is_json_output() {
102        print_json(&result)?;
103        return Ok(());
104    }
105    println!(
106        "{GREEN}\u{2713}{RESET} Agent name parked: {}",
107        qualified(did, name)
108    );
109    println!(
110        "  {DIM}It no longer resolves, but stays reserved to this DID. \
111         Re-enable with `agent-names enable`.{RESET}"
112    );
113    Ok(())
114}
115
116pub async fn cmd_agent_name_enable(
117    client: &VtaClient,
118    did: &str,
119    name: &str,
120) -> Result<(), Box<dyn std::error::Error>> {
121    let result = client.enable_agent_name(did, name).await?;
122    if is_json_output() {
123        print_json(&result)?;
124        return Ok(());
125    }
126    println!(
127        "{GREEN}\u{2713}{RESET} Agent name back in service: {}",
128        qualified(did, name)
129    );
130    Ok(())
131}
132
133pub async fn cmd_agent_name_list(
134    client: &VtaClient,
135    did: &str,
136) -> Result<(), Box<dyn std::error::Error>> {
137    let result = client.list_agent_names(did).await?;
138    if is_json_output() {
139        print_json(&result)?;
140        return Ok(());
141    }
142
143    if result.names.is_empty() {
144        println!("No agent names bound to {}.", shorten_did(did));
145        println!("  {DIM}Bind one with `agent-names set --did <did> --name <name>`.{RESET}");
146        return Ok(());
147    }
148
149    println!();
150    println!("{BOLD}Agent names for {}{RESET}", shorten_did(did));
151    println!();
152    for entry in &result.names {
153        // A parked name is deliberately absent from the DID document, so it
154        // would be invisible if we only read the document — this listing comes
155        // from the hosting registry, which is why it can show them at all.
156        let state = if entry.enabled {
157            format!("{GREEN}resolves{RESET}")
158        } else {
159            format!("{YELLOW}parked{RESET}")
160        };
161        println!("  {BOLD}{}{RESET}  {state}", qualified(did, &entry.name));
162    }
163    println!();
164    Ok(())
165}
166
167pub async fn cmd_agent_name_check(
168    client: &VtaClient,
169    did: &str,
170    name: &str,
171) -> Result<(), Box<dyn std::error::Error>> {
172    let result = client.check_agent_name(did, name).await?;
173    if is_json_output() {
174        print_json(&result)?;
175        return Ok(());
176    }
177
178    let full = format!("{}/@{}", result.domain, result.name);
179    if result.reserved {
180        println!("{YELLOW}\u{2717}{RESET} {full} is reserved.");
181        println!(
182            "  {DIM}Reserved names (admin, api, support, …) are withheld to \
183             make impersonation harder.{RESET}"
184        );
185    } else if result.available {
186        println!("{GREEN}\u{2713}{RESET} {full} is available.");
187        println!(
188            "  {DIM}Advisory only — it can be taken before you bind it, so \
189             `set` reports its own conflict.{RESET}"
190        );
191    } else {
192        println!("{YELLOW}\u{2717}{RESET} {full} is taken.");
193    }
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn domain_comes_from_the_did_not_the_operator() {
203        assert_eq!(
204            domain_of("did:webvh:QmScid:webvh.storm.ws:glenn-vta").as_deref(),
205            Some("webvh.storm.ws")
206        );
207        assert_eq!(
208            domain_of("did:web:QmScid:example.com").as_deref(),
209            Some("example.com")
210        );
211    }
212
213    #[test]
214    fn a_ported_host_is_percent_decoded() {
215        // Hosting encodes the colon; rendering it raw would print a name no
216        // operator could paste back.
217        assert_eq!(
218            domain_of("did:webvh:QmScid:localhost%3A8534").as_deref(),
219            Some("localhost:8534")
220        );
221    }
222
223    #[test]
224    fn a_non_web_did_has_no_domain() {
225        assert!(domain_of("did:key:z6MkfrQjWz").is_none());
226        assert!(domain_of("not-a-did").is_none());
227    }
228
229    #[test]
230    fn qualified_falls_back_to_the_bare_local_part() {
231        // Better than inventing a domain we cannot derive.
232        assert_eq!(qualified("did:key:z6MkfrQjWz", "ops"), "@ops");
233        assert_eq!(
234            qualified("did:webvh:QmScid:webvh.storm.ws", "ops"),
235            "webvh.storm.ws/@ops"
236        );
237    }
238}