vta_sdk/display_name/mod.rs
1//! DID → human-readable display name.
2//!
3//! Operators read DIDs constantly and can't. Every operator-facing surface in
4//! the workspace — the PNM/CNM CLIs, the VTC operator CLI, the VTC admin UI —
5//! prints raw DIDs, each truncating them its own way. This module is the one
6//! seam they all render through.
7//!
8//! # The model: a book the caller fills, not a resolver that fetches
9//!
10//! [`NameBook`] is a plain `DID → name` map. Commands populate it from data
11//! they *already hold* and then query it while rendering. That shape is what
12//! keeps naming free: `acl list` already receives every entry with its
13//! `label`, so filling the book from that one response also names the
14//! `created_by` column — a DID that has no label of its own but is very often
15//! another entry's subject. No extra request, no N+1 lookup.
16//!
17//! Nothing here performs I/O. The one source that needs the network — a
18//! verified agent name — lives in [`agent_name`] behind the `agent-names`
19//! feature and hands its result back as a [`DisplayName`] like any other.
20//!
21//! # Where names come from
22//!
23//! Two kinds of source, and the difference is the whole reason [`NameSource`]
24//! exists rather than a bare `String`.
25//!
26//! **Local**: `AclEntry.label`, `ContextRecord.name`,
27//! `WebvhServerRecord.label`, the PNM/CNM per-VTA config `name`. Operator-typed,
28//! from our own store, and free to read — they arrive on responses the caller
29//! already fetched.
30//!
31//! **Agent names**: `domain/@name` bound to a DID by
32//! `pnm did-mgmt agent-names set`, which writes the claim into the DID
33//! document's `alsoKnownAs` and republishes the signed log. Reading one back
34//! costs network and is opt-in per invocation (`--resolve-agent-names`), since
35//! a verified lookup is a DID resolution plus an outbound fetch per claim.
36//!
37//! # Trust
38//!
39//! A name is a claim, and the claims differ in who is making them.
40//!
41//! A local label was typed by the operator into their own store. A *verified*
42//! agent name round-tripped: the DID's document claimed it and resolving the
43//! name led back to that same DID.
44//!
45//! An **unverified** agent name is neither. `alsoKnownAs` is self-asserted —
46//! the two-sided binding in the agent-name specification protects the
47//! name→DID direction, not the reverse — so a hostile DID can claim
48//! `alsoKnownAs: ["mybank.com/@treasury"]` and anything that renders that
49//! bare has just told the operator a lie in an authoritative voice. Such
50//! names sort *below* every local source in [`NameSource::rank`] and report
51//! [`DisplayName::is_trusted`] `== false`, and [`NameBook::render_inline`]
52//! tags them. Surfaces must not strip that tag.
53
54use std::collections::HashMap;
55
56#[cfg(feature = "agent-names")]
57pub mod agent_name;
58
59/// Where a display name came from.
60///
61/// Kept as a distinct type rather than folding everything into a `String`
62/// because the sources are not equally trustworthy, and the difference has to
63/// survive all the way to the pixel — see the module-level note on trust.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum NameSource {
66 /// An `alsoKnownAs` entry on the DID's own document.
67 ///
68 /// `verified` means the name was resolved forward and led back to this
69 /// same DID. `verified == false` is a bare self-assertion and is the
70 /// least trustworthy source there is.
71 AgentName { verified: bool },
72 /// `AclEntry.label` / `VtcAclEntry.label` — operator-typed, our store.
73 AclLabel,
74 /// A `DeviceBinding.display_name` from an agent device registration.
75 DeviceName,
76 /// The `name` of a locally-configured VTA / community (`~/.config/{pnm,cnm}`).
77 LocalAlias,
78 /// `WebvhServerRecord.label` — names a DID-hosting server.
79 ServerLabel,
80 /// `ContextRecord.name` — names a context, used for the context's own DID.
81 ContextName,
82}
83
84impl NameSource {
85 /// Precedence when two sources name the same DID. Higher wins.
86 ///
87 /// A verified agent name outranks everything: it is globally meaningful
88 /// and cryptographically bound to the DID, where a local label is one
89 /// operator's private note. An *unverified* agent name ranks below every
90 /// local source for the reason in the module docs — the operator's own
91 /// data must never be displaced by a stranger's unchecked claim.
92 #[must_use]
93 pub fn rank(self) -> u8 {
94 match self {
95 Self::AgentName { verified: true } => 100,
96 Self::AclLabel => 60,
97 Self::LocalAlias => 50,
98 Self::DeviceName => 45,
99 Self::ServerLabel => 40,
100 Self::ContextName => 30,
101 Self::AgentName { verified: false } => 10,
102 }
103 }
104
105 /// Stable machine-readable tag, used for the `nameSource` field in
106 /// `--json` output. Kebab-case so it reads well through `jq`.
107 #[must_use]
108 pub fn as_str(self) -> &'static str {
109 match self {
110 Self::AgentName { verified: true } => "agent-name",
111 Self::AgentName { verified: false } => "agent-name-unverified",
112 Self::AclLabel => "acl-label",
113 Self::DeviceName => "device-name",
114 Self::LocalAlias => "local-alias",
115 Self::ServerLabel => "server-label",
116 Self::ContextName => "context-name",
117 }
118 }
119
120 /// Whether a name from this source may be shown without qualification.
121 ///
122 /// False only for an unverified agent name.
123 #[must_use]
124 pub fn is_trusted(self) -> bool {
125 !matches!(self, Self::AgentName { verified: false })
126 }
127}
128
129impl serde::Serialize for NameSource {
130 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
131 s.serialize_str(self.as_str())
132 }
133}
134
135/// A name for a DID, and the provenance of that name.
136#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct DisplayName {
139 pub name: String,
140 pub source: NameSource,
141}
142
143impl DisplayName {
144 pub fn new(name: impl Into<String>, source: NameSource) -> Self {
145 Self {
146 name: name.into(),
147 source,
148 }
149 }
150
151 /// See [`NameSource::is_trusted`].
152 #[must_use]
153 pub fn is_trusted(&self) -> bool {
154 self.source.is_trusted()
155 }
156}
157
158/// Marker appended to a name that has not been verified. Surfaces may
159/// restyle it (the CLI colours it yellow) but must not drop it.
160pub const UNVERIFIED_SUFFIX: &str = " [unverified]";
161
162/// A `DID → name` map, populated by the caller from data it already holds.
163///
164/// Insertion is idempotent and order-independent: a lower-ranked source never
165/// displaces a higher-ranked one, so a command can fill the book from several
166/// responses in whatever order they arrive without the result depending on
167/// that order.
168#[derive(Debug, Clone, Default)]
169pub struct NameBook {
170 entries: HashMap<String, DisplayName>,
171}
172
173impl NameBook {
174 #[must_use]
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 /// Record a name for `did`, keeping whichever source ranks higher.
180 ///
181 /// Empty and whitespace-only names are dropped rather than stored: an
182 /// unset label deserialises as `Some("")` often enough that storing it
183 /// would put a blank string where the DID should be.
184 pub fn insert(&mut self, did: impl Into<String>, name: DisplayName) {
185 if name.name.trim().is_empty() {
186 return;
187 }
188 let did = did.into();
189 match self.entries.get(&did) {
190 Some(existing) if existing.source.rank() >= name.source.rank() => {}
191 _ => {
192 self.entries.insert(did, name);
193 }
194 }
195 }
196
197 /// Convenience for the common case: an `Option<String>` label straight
198 /// off a response struct.
199 pub fn insert_opt(&mut self, did: impl Into<String>, name: Option<&str>, source: NameSource) {
200 if let Some(n) = name {
201 self.insert(did, DisplayName::new(n, source));
202 }
203 }
204
205 #[must_use]
206 pub fn get(&self, did: &str) -> Option<&DisplayName> {
207 self.entries.get(did)
208 }
209
210 #[must_use]
211 pub fn is_empty(&self) -> bool {
212 self.entries.is_empty()
213 }
214
215 #[must_use]
216 pub fn len(&self) -> usize {
217 self.entries.len()
218 }
219
220 /// Whether any DID in `dids` has a name.
221 ///
222 /// Table renderers call this to decide whether to emit a NAME column at
223 /// all — on a VTA where nothing has been labelled, a column of dashes is
224 /// worse than no column.
225 pub fn names_any<'a>(&self, dids: impl IntoIterator<Item = &'a str>) -> bool {
226 dids.into_iter().any(|d| self.entries.contains_key(d))
227 }
228
229 /// The name for `did` as a bare string, tagged when unverified.
230 ///
231 /// Returns `None` when the DID has no name, so callers can decide between
232 /// a placeholder and the shortened DID.
233 #[must_use]
234 pub fn name_of(&self, did: &str) -> Option<String> {
235 self.entries.get(did).map(|n| {
236 if n.is_trusted() {
237 n.name.clone()
238 } else {
239 format!("{}{UNVERIFIED_SUFFIX}", n.name)
240 }
241 })
242 }
243
244 /// One-line rendering for prose and confirmations:
245 /// `mediator-prod (did:webvh:QmXk…9f2:example.com)`.
246 ///
247 /// Falls back to the shortened DID alone when there is no name. The DID is
248 /// always present — a name the operator cannot cross-check against an
249 /// identifier is a name they cannot audit.
250 #[must_use]
251 pub fn render_inline(&self, did: &str) -> String {
252 match self.name_of(did) {
253 Some(name) => format!("{name} ({})", shorten_did(did)),
254 None => shorten_did(did),
255 }
256 }
257}
258
259/// Shorten a DID for a fixed-width cell while keeping the part that
260/// identifies it.
261///
262/// A `did:webvh` / `did:web` carries its meaning in the *tail* — the domain
263/// and path, e.g. `…:webvh.storm.ws:glenn-vta` — and its noise in the middle:
264/// the SCID, a content hash. So the SCID is abbreviated and the path kept
265/// verbatim. A `did:webvh` host longer than 12 characters with subdomains
266/// keeps only its last two labels (`webvh.storm.ws` → `…storm.ws`): the path
267/// is what tells two DIDs on one host apart, so the host yields first. That
268/// is the opposite of a CSS-style trailing ellipsis, which clips off
269/// precisely the informative half.
270///
271/// Other methods (`did:key` and friends) have no human tail, so the opaque id
272/// is middle-truncated instead, keeping a head and a tail so an operator can
273/// still tell two of them apart at a glance.
274///
275/// Inputs that aren't DIDs, and DIDs already short enough, are returned
276/// unchanged.
277///
278/// This is a port of `shortenDid` in
279/// `vtc-service/admin-ui/src/lib/format.ts`. The two must agree — an operator
280/// moves between a terminal and the admin console looking at the same
281/// community, and a DID abbreviated two ways is one they re-identify on every
282/// switch. The vector table in the tests below is reproduced in that file's
283/// doc comment and asserted by its `format.test.ts`.
284#[must_use]
285pub fn shorten_did(did: &str) -> String {
286 shorten_did_keep(did, DEFAULT_KEEP)
287}
288
289/// Number of leading characters kept from an opaque DID segment.
290pub const DEFAULT_KEEP: usize = 10;
291
292/// Trailing characters kept when middle-truncating a non-`webvh` DID.
293const TAIL: usize = 6;
294
295#[must_use]
296pub fn shorten_did_keep(did: &str, keep: usize) -> String {
297 if !did.starts_with("did:") {
298 return did.to_string();
299 }
300 // `did:scid:vh:1:<scid>[?src=<host>[/<path>]]`: the SCID abbreviated like a
301 // did:webvh's, and the `src` host like its host, the path kept.
302 if let Some(rest) = did.strip_prefix("did:scid:vh:1:") {
303 let (scid, src) = match rest.split_once("?src=") {
304 Some((scid, src)) => (scid, Some(src)),
305 None => (rest, None),
306 };
307 let mut out = String::from("did:scid:vh:1:");
308 if char_len(scid) > keep + 1 {
309 out.push_str(&take_chars(scid, keep));
310 out.push('…');
311 } else {
312 out.push_str(scid);
313 }
314 if let Some(src) = src {
315 let (host, path) = match src.split_once('/') {
316 Some((h, p)) => (h, Some(p)),
317 None => (src, None),
318 };
319 out.push_str("?src=");
320 out.push_str(&shorten_host(host));
321 if let Some(path) = path {
322 out.push('/');
323 out.push_str(path);
324 }
325 }
326 return out;
327 }
328
329 let parts: Vec<&str> = did.split(':').collect();
330 let method = parts.get(1).copied().unwrap_or_default();
331
332 if (method == "webvh" || method == "web") && parts.len() > 3 {
333 let mut out: Vec<String> = parts.iter().map(|p| (*p).to_string()).collect();
334 let scid = parts[2];
335 if char_len(scid) > keep + 1 {
336 out[2] = format!("{}…", take_chars(scid, keep));
337 }
338 // The path after the host is what tells two DIDs on one host apart,
339 // so a long host gives up its subdomains before the path gives up
340 // anything.
341 if method == "webvh" {
342 out[3] = shorten_host(parts[3]);
343 }
344 return out.join(":");
345 }
346
347 // `did:<method>:<id>`, where `<id>` may itself contain colons.
348 let id = parts[2..].join(":");
349 if char_len(&id) <= keep + TAIL + 1 {
350 return did.to_string();
351 }
352 format!(
353 "{}:{}:{}…{}",
354 parts[0],
355 method,
356 take_chars(&id, keep),
357 take_last_chars(&id, TAIL)
358 )
359}
360
361/// Hosts up to this many characters are shown whole.
362const HOST_KEEP: usize = 12;
363
364/// `webvh.storm.ws` → `…storm.ws`: a host longer than [`HOST_KEEP`] with
365/// subdomains keeps its last two labels. A two-label host is left whole.
366fn shorten_host(host: &str) -> String {
367 let labels: Vec<&str> = host.split('.').collect();
368 if labels.len() > 2 && char_len(host) > HOST_KEEP {
369 format!("…{}", labels[labels.len() - 2..].join("."))
370 } else {
371 host.to_string()
372 }
373}
374
375fn char_len(s: &str) -> usize {
376 s.chars().count()
377}
378
379fn take_chars(s: &str, n: usize) -> String {
380 s.chars().take(n).collect()
381}
382
383fn take_last_chars(s: &str, n: usize) -> String {
384 let len = char_len(s);
385 s.chars().skip(len.saturating_sub(n)).collect()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 // ── shorten_did ─────────────────────────────────────────────────
393 //
394 // These vectors are reproduced in the doc comment on `shortenDid` in
395 // `vtc-service/admin-ui/src/lib/format.ts` and asserted there too, by
396 // `format.test.ts`. The Rust CLIs and the
397 // React admin UI show the same operator the same DIDs; if they abbreviate
398 // differently, the operator cannot tell whether two screens are showing
399 // one identity or two. Change one side and you must change the other.
400 const VECTORS: &[(&str, &str)] = &[
401 // Not a DID — untouched.
402 ("alice", "alice"),
403 ("https://example.com/@alice", "https://example.com/@alice"),
404 // webvh: SCID abbreviated, a long host's subdomains dropped, the path
405 // kept in full.
406 (
407 "did:webvh:QmXkAbCdEfGhIjKlMnOp:webvh.storm.ws:glenn-vta",
408 "did:webvh:QmXkAbCdEf…:…storm.ws:glenn-vta",
409 ),
410 (
411 "did:webvh:QmXkAbCdEfGhIjKlMnOp:dids.firstperson.dev:stem-wall",
412 "did:webvh:QmXkAbCdEf…:…firstperson.dev:stem-wall",
413 ),
414 // A short host, or one without subdomains, is kept whole.
415 (
416 "did:webvh:QmXkAbCdEfGhIjKlMnOp:dids.ic3.dev:fruit-feel",
417 "did:webvh:QmXkAbCdEf…:dids.ic3.dev:fruit-feel",
418 ),
419 (
420 "did:webvh:QmXkAbCdEfGhIjKlMnOp:firstperson.network:a:b",
421 "did:webvh:QmXkAbCdEf…:firstperson.network:a:b",
422 ),
423 // web: same rule.
424 (
425 "did:web:QmXkAbCdEfGhIjKlMnOp:example.com",
426 "did:web:QmXkAbCdEf…:example.com",
427 ),
428 // webvh with a short SCID — nothing to gain, left alone.
429 ("did:webvh:Qm123:example.com", "did:webvh:Qm123:example.com"),
430 // did:key — no human tail, so middle-truncate head + tail.
431 (
432 "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT",
433 "did:key:z6MkfrQjWz…XyZ4rT",
434 ),
435 // Short did:key — under the threshold, untouched.
436 ("did:key:z6MkfrQjWz", "did:key:z6MkfrQjWz"),
437 // A webvh DID with only 3 segments has no domain tail to protect, so
438 // it falls through to the generic middle-truncating arm.
439 (
440 "did:webvh:QmXkAbCdEfGhIjKlMnOpQrSt",
441 "did:webvh:QmXkAbCdEf…OpQrSt",
442 ),
443 // did:scid:vh:1 — the SCID like a webvh's, the `src` host like its
444 // host, the path kept; a short one, and a bare one, as they are.
445 (
446 "did:scid:vh:1:QmXkAbCdEfGhIjKlMnOp?src=webvh.storm.ws/glenn-vta",
447 "did:scid:vh:1:QmXkAbCdEf…?src=…storm.ws/glenn-vta",
448 ),
449 (
450 "did:scid:vh:1:QmXkAbCdEfGhIjKlMnOp",
451 "did:scid:vh:1:QmXkAbCdEf…",
452 ),
453 (
454 "did:scid:vh:1:Qm123?src=example.com",
455 "did:scid:vh:1:Qm123?src=example.com",
456 ),
457 ];
458
459 #[test]
460 fn shorten_did_matches_shared_vectors() {
461 for (input, expected) in VECTORS {
462 assert_eq!(&shorten_did(input), expected, "input: {input}");
463 }
464 }
465
466 #[test]
467 fn shorten_did_is_char_safe() {
468 // Non-ASCII in the opaque segment must not panic on a byte boundary.
469 let did = "did:key:zÄÖÜäöüßÅÆØåæøΩμλ0123456789";
470 let out = shorten_did(did);
471 assert!(out.starts_with("did:key:"));
472 assert!(out.contains('…'));
473 }
474
475 // ── NameSource precedence ───────────────────────────────────────
476
477 #[test]
478 fn verified_agent_name_outranks_local_label() {
479 assert!(
480 NameSource::AgentName { verified: true }.rank() > NameSource::AclLabel.rank(),
481 "a cryptographically bound global name beats one operator's private note"
482 );
483 }
484
485 #[test]
486 fn unverified_agent_name_ranks_below_every_local_source() {
487 let unverified = NameSource::AgentName { verified: false }.rank();
488 for local in [
489 NameSource::AclLabel,
490 NameSource::DeviceName,
491 NameSource::LocalAlias,
492 NameSource::ServerLabel,
493 NameSource::ContextName,
494 ] {
495 assert!(
496 local.rank() > unverified,
497 "{local:?} must not be displaced by an unchecked claim"
498 );
499 }
500 }
501
502 #[test]
503 fn only_unverified_agent_names_are_untrusted() {
504 assert!(!NameSource::AgentName { verified: false }.is_trusted());
505 assert!(NameSource::AgentName { verified: true }.is_trusted());
506 assert!(NameSource::AclLabel.is_trusted());
507 }
508
509 // ── NameBook ────────────────────────────────────────────────────
510
511 const DID_A: &str = "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT";
512
513 #[test]
514 fn insert_is_order_independent() {
515 let strong = DisplayName::new(
516 "agent.example/@ops",
517 NameSource::AgentName { verified: true },
518 );
519 let weak = DisplayName::new("my-note", NameSource::AclLabel);
520
521 let mut a = NameBook::new();
522 a.insert(DID_A, weak.clone());
523 a.insert(DID_A, strong.clone());
524
525 let mut b = NameBook::new();
526 b.insert(DID_A, strong.clone());
527 b.insert(DID_A, weak);
528
529 assert_eq!(a.get(DID_A), b.get(DID_A));
530 assert_eq!(a.get(DID_A), Some(&strong));
531 }
532
533 #[test]
534 fn unverified_claim_never_displaces_an_operator_label() {
535 let mut book = NameBook::new();
536 book.insert(DID_A, DisplayName::new("payroll-bot", NameSource::AclLabel));
537 book.insert(
538 DID_A,
539 DisplayName::new(
540 "mybank.com/@treasury",
541 NameSource::AgentName { verified: false },
542 ),
543 );
544 assert_eq!(
545 book.get(DID_A).map(|n| n.name.as_str()),
546 Some("payroll-bot")
547 );
548 }
549
550 #[test]
551 fn unverified_names_are_tagged_when_rendered() {
552 let mut book = NameBook::new();
553 book.insert(
554 DID_A,
555 DisplayName::new(
556 "mybank.com/@treasury",
557 NameSource::AgentName { verified: false },
558 ),
559 );
560 let rendered = book.name_of(DID_A).unwrap();
561 assert!(
562 rendered.contains("unverified"),
563 "a self-asserted name must never render bare: {rendered}"
564 );
565 assert!(book.render_inline(DID_A).contains("unverified"));
566 }
567
568 #[test]
569 fn blank_labels_are_not_stored() {
570 let mut book = NameBook::new();
571 book.insert(DID_A, DisplayName::new("", NameSource::AclLabel));
572 book.insert(DID_A, DisplayName::new(" ", NameSource::AclLabel));
573 assert!(book.is_empty(), "a blank label must not shadow the DID");
574 }
575
576 #[test]
577 fn insert_opt_skips_none() {
578 let mut book = NameBook::new();
579 book.insert_opt(DID_A, None, NameSource::AclLabel);
580 assert!(book.is_empty());
581 book.insert_opt(DID_A, Some("ops"), NameSource::AclLabel);
582 assert_eq!(book.len(), 1);
583 }
584
585 #[test]
586 fn render_inline_always_shows_the_did() {
587 let mut book = NameBook::new();
588 book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
589 let rendered = book.render_inline(DID_A);
590 assert!(rendered.starts_with("ops ("));
591 assert!(
592 rendered.contains("did:key:"),
593 "the operator must be able to audit the name against an identifier"
594 );
595 }
596
597 #[test]
598 fn render_inline_falls_back_to_the_shortened_did() {
599 let book = NameBook::new();
600 assert_eq!(book.render_inline(DID_A), shorten_did(DID_A));
601 }
602
603 #[test]
604 fn names_any_drives_the_optional_name_column() {
605 let mut book = NameBook::new();
606 assert!(!book.names_any([DID_A, "did:key:zOther"]));
607 book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
608 assert!(book.names_any([DID_A, "did:key:zOther"]));
609 assert!(!book.names_any(["did:key:zOther"]));
610 }
611
612 #[test]
613 fn name_source_json_tags_are_stable() {
614 // These strings appear in `--json` output; scripts key off them.
615 assert_eq!(
616 serde_json::to_string(&NameSource::AgentName { verified: false }).unwrap(),
617 "\"agent-name-unverified\""
618 );
619 assert_eq!(
620 serde_json::to_string(&NameSource::AclLabel).unwrap(),
621 "\"acl-label\""
622 );
623 }
624}