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