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 everything after
265/// it kept verbatim. That is the opposite of a CSS-style trailing ellipsis,
266/// which clips off precisely the informative half.
267///
268/// Other methods (`did:key` and friends) have no human tail, so the opaque id
269/// is middle-truncated instead, keeping a head and a tail so an operator can
270/// still tell two of them apart at a glance.
271///
272/// Inputs that aren't DIDs, and DIDs already short enough, are returned
273/// unchanged.
274///
275/// This is a port of `shortenDid` in
276/// `vtc-service/admin-ui/src/lib/format.ts`. The two must agree — an operator
277/// moves between a terminal and the admin console looking at the same
278/// community, and a DID abbreviated two ways is one they re-identify on every
279/// switch. The vector table in the tests below is the authority (the console
280/// has no test runner); it is reproduced in that file's doc comment.
281#[must_use]
282pub fn shorten_did(did: &str) -> String {
283 shorten_did_keep(did, DEFAULT_KEEP)
284}
285
286/// Number of leading characters kept from an opaque DID segment.
287pub const DEFAULT_KEEP: usize = 10;
288
289/// Trailing characters kept when middle-truncating a non-`webvh` DID.
290const TAIL: usize = 6;
291
292#[must_use]
293pub fn shorten_did_keep(did: &str, keep: usize) -> String {
294 if !did.starts_with("did:") {
295 return did.to_string();
296 }
297 let parts: Vec<&str> = did.split(':').collect();
298 let method = parts.get(1).copied().unwrap_or_default();
299
300 if (method == "webvh" || method == "web") && parts.len() > 3 {
301 let scid = parts[2];
302 if char_len(scid) <= keep + 1 {
303 return did.to_string();
304 }
305 let mut out = parts.clone();
306 let abbreviated = format!("{}…", take_chars(scid, keep));
307 out[2] = &abbreviated;
308 return out.join(":");
309 }
310
311 // `did:<method>:<id>`, where `<id>` may itself contain colons.
312 let id = parts[2..].join(":");
313 if char_len(&id) <= keep + TAIL + 1 {
314 return did.to_string();
315 }
316 format!(
317 "{}:{}:{}…{}",
318 parts[0],
319 method,
320 take_chars(&id, keep),
321 take_last_chars(&id, TAIL)
322 )
323}
324
325fn char_len(s: &str) -> usize {
326 s.chars().count()
327}
328
329fn take_chars(s: &str, n: usize) -> String {
330 s.chars().take(n).collect()
331}
332
333fn take_last_chars(s: &str, n: usize) -> String {
334 let len = char_len(s);
335 s.chars().skip(len.saturating_sub(n)).collect()
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 // ── shorten_did ─────────────────────────────────────────────────
343 //
344 // These vectors are reproduced in the doc comment on `shortenDid` in
345 // `vtc-service/admin-ui/src/lib/format.ts`, which has no test runner of
346 // its own — this test is the authority for both. The Rust CLIs and the
347 // React admin UI show the same operator the same DIDs; if they abbreviate
348 // differently, the operator cannot tell whether two screens are showing
349 // one identity or two. Change one side and you must change the other.
350 const VECTORS: &[(&str, &str)] = &[
351 // Not a DID — untouched.
352 ("alice", "alice"),
353 ("https://example.com/@alice", "https://example.com/@alice"),
354 // webvh: SCID abbreviated, domain + path tail kept in full.
355 (
356 "did:webvh:QmXkAbCdEfGhIjKlMnOp:webvh.storm.ws:glenn-vta",
357 "did:webvh:QmXkAbCdEf…:webvh.storm.ws:glenn-vta",
358 ),
359 // web: same rule.
360 (
361 "did:web:QmXkAbCdEfGhIjKlMnOp:example.com",
362 "did:web:QmXkAbCdEf…:example.com",
363 ),
364 // webvh with a short SCID — nothing to gain, left alone.
365 ("did:webvh:Qm123:example.com", "did:webvh:Qm123:example.com"),
366 // did:key — no human tail, so middle-truncate head + tail.
367 (
368 "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT",
369 "did:key:z6MkfrQjWz…XyZ4rT",
370 ),
371 // Short did:key — under the threshold, untouched.
372 ("did:key:z6MkfrQjWz", "did:key:z6MkfrQjWz"),
373 // A webvh DID with only 3 segments has no domain tail to protect, so
374 // it falls through to the generic middle-truncating arm.
375 (
376 "did:webvh:QmXkAbCdEfGhIjKlMnOpQrSt",
377 "did:webvh:QmXkAbCdEf…OpQrSt",
378 ),
379 ];
380
381 #[test]
382 fn shorten_did_matches_shared_vectors() {
383 for (input, expected) in VECTORS {
384 assert_eq!(&shorten_did(input), expected, "input: {input}");
385 }
386 }
387
388 #[test]
389 fn shorten_did_is_char_safe() {
390 // Non-ASCII in the opaque segment must not panic on a byte boundary.
391 let did = "did:key:zÄÖÜäöüßÅÆØåæøΩμλ0123456789";
392 let out = shorten_did(did);
393 assert!(out.starts_with("did:key:"));
394 assert!(out.contains('…'));
395 }
396
397 // ── NameSource precedence ───────────────────────────────────────
398
399 #[test]
400 fn verified_agent_name_outranks_local_label() {
401 assert!(
402 NameSource::AgentName { verified: true }.rank() > NameSource::AclLabel.rank(),
403 "a cryptographically bound global name beats one operator's private note"
404 );
405 }
406
407 #[test]
408 fn unverified_agent_name_ranks_below_every_local_source() {
409 let unverified = NameSource::AgentName { verified: false }.rank();
410 for local in [
411 NameSource::AclLabel,
412 NameSource::DeviceName,
413 NameSource::LocalAlias,
414 NameSource::ServerLabel,
415 NameSource::ContextName,
416 ] {
417 assert!(
418 local.rank() > unverified,
419 "{local:?} must not be displaced by an unchecked claim"
420 );
421 }
422 }
423
424 #[test]
425 fn only_unverified_agent_names_are_untrusted() {
426 assert!(!NameSource::AgentName { verified: false }.is_trusted());
427 assert!(NameSource::AgentName { verified: true }.is_trusted());
428 assert!(NameSource::AclLabel.is_trusted());
429 }
430
431 // ── NameBook ────────────────────────────────────────────────────
432
433 const DID_A: &str = "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT";
434
435 #[test]
436 fn insert_is_order_independent() {
437 let strong = DisplayName::new(
438 "agent.example/@ops",
439 NameSource::AgentName { verified: true },
440 );
441 let weak = DisplayName::new("my-note", NameSource::AclLabel);
442
443 let mut a = NameBook::new();
444 a.insert(DID_A, weak.clone());
445 a.insert(DID_A, strong.clone());
446
447 let mut b = NameBook::new();
448 b.insert(DID_A, strong.clone());
449 b.insert(DID_A, weak);
450
451 assert_eq!(a.get(DID_A), b.get(DID_A));
452 assert_eq!(a.get(DID_A), Some(&strong));
453 }
454
455 #[test]
456 fn unverified_claim_never_displaces_an_operator_label() {
457 let mut book = NameBook::new();
458 book.insert(DID_A, DisplayName::new("payroll-bot", NameSource::AclLabel));
459 book.insert(
460 DID_A,
461 DisplayName::new(
462 "mybank.com/@treasury",
463 NameSource::AgentName { verified: false },
464 ),
465 );
466 assert_eq!(
467 book.get(DID_A).map(|n| n.name.as_str()),
468 Some("payroll-bot")
469 );
470 }
471
472 #[test]
473 fn unverified_names_are_tagged_when_rendered() {
474 let mut book = NameBook::new();
475 book.insert(
476 DID_A,
477 DisplayName::new(
478 "mybank.com/@treasury",
479 NameSource::AgentName { verified: false },
480 ),
481 );
482 let rendered = book.name_of(DID_A).unwrap();
483 assert!(
484 rendered.contains("unverified"),
485 "a self-asserted name must never render bare: {rendered}"
486 );
487 assert!(book.render_inline(DID_A).contains("unverified"));
488 }
489
490 #[test]
491 fn blank_labels_are_not_stored() {
492 let mut book = NameBook::new();
493 book.insert(DID_A, DisplayName::new("", NameSource::AclLabel));
494 book.insert(DID_A, DisplayName::new(" ", NameSource::AclLabel));
495 assert!(book.is_empty(), "a blank label must not shadow the DID");
496 }
497
498 #[test]
499 fn insert_opt_skips_none() {
500 let mut book = NameBook::new();
501 book.insert_opt(DID_A, None, NameSource::AclLabel);
502 assert!(book.is_empty());
503 book.insert_opt(DID_A, Some("ops"), NameSource::AclLabel);
504 assert_eq!(book.len(), 1);
505 }
506
507 #[test]
508 fn render_inline_always_shows_the_did() {
509 let mut book = NameBook::new();
510 book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
511 let rendered = book.render_inline(DID_A);
512 assert!(rendered.starts_with("ops ("));
513 assert!(
514 rendered.contains("did:key:"),
515 "the operator must be able to audit the name against an identifier"
516 );
517 }
518
519 #[test]
520 fn render_inline_falls_back_to_the_shortened_did() {
521 let book = NameBook::new();
522 assert_eq!(book.render_inline(DID_A), shorten_did(DID_A));
523 }
524
525 #[test]
526 fn names_any_drives_the_optional_name_column() {
527 let mut book = NameBook::new();
528 assert!(!book.names_any([DID_A, "did:key:zOther"]));
529 book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
530 assert!(book.names_any([DID_A, "did:key:zOther"]));
531 assert!(!book.names_any(["did:key:zOther"]));
532 }
533
534 #[test]
535 fn name_source_json_tags_are_stable() {
536 // These strings appear in `--json` output; scripts key off them.
537 assert_eq!(
538 serde_json::to_string(&NameSource::AgentName { verified: false }).unwrap(),
539 "\"agent-name-unverified\""
540 );
541 assert_eq!(
542 serde_json::to_string(&NameSource::AclLabel).unwrap(),
543 "\"acl-label\""
544 );
545 }
546}