Skip to main content

nula_core/nips/
nip89.rs

1//! [NIP-89] Recommended Application Handlers.
2//!
3//! Two addressable event kinds plus one optional `client` tag:
4//!
5//! - **`kind: 31989`** — recommendation. The `d` tag is the
6//!   recommended event-kind (rendered as a decimal string), and one
7//!   or more `a` tags point at the handler events with an optional
8//!   relay hint and platform marker.
9//! - **`kind: 31990`** — handler. The `d` tag is a free-form
10//!   identifier, the `content` is optional `kind: 0`-shaped metadata,
11//!   each supported event-kind is listed in a `k` tag, and the entry
12//!   URLs are encoded as platform tags (`web`, `ios`, …) carrying
13//!   the URL template and an optional NIP-19 entity hint.
14//! - **`client` tag** — events MAY include a `client` tag to advertise
15//!   the authoring application (name + handler coordinate + relay
16//!   hint). The tag is parsed by [`ClientTag::from_tag`] and built by
17//!   [`ClientTag::to_tag`] / [`Tag::client`].
18//!
19//! # Forward compatibility
20//!
21//! - Platform names are opaque strings — new platforms work without a
22//!   spec bump.
23//! - Unknown extra tags survive a round-trip through `extra_tags` on
24//!   both bundles.
25//! - Recommendation `a` columns past the third position are preserved
26//!   in [`HandlerRecommendationEntry::extra_columns`].
27//!
28//! [NIP-89]: https://github.com/nostr-protocol/nips/blob/master/89.md
29
30use thiserror::Error;
31
32use crate::event::{
33    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, Kind, SingleLetterTag, Tag,
34    TagKind, Tags,
35};
36use crate::types::{RelayUrl, RelayUrlError};
37
38/// `kind: 31989` — recommendation event.
39pub const KIND_APP_RECOMMENDATION: Kind = Kind::APP_RECOMMENDATION;
40
41/// `kind: 31990` — handler event.
42pub const KIND_APP_HANDLER: Kind = Kind::APP_HANDLER;
43
44/// Wire name of the optional `client` tag.
45pub const CLIENT_TAG: &str = "client";
46
47/// One recommendation row inside a `kind: 31989` event.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct HandlerRecommendationEntry {
50    /// Handler coordinate (`kind: 31990` addressable event).
51    pub handler: Coordinate,
52    /// Optional relay hint where the handler can be fetched.
53    pub relay_hint: Option<RelayUrl>,
54    /// Optional platform marker (`web`, `ios`, `android`, …).
55    pub platform: Option<String>,
56    /// Any further columns the producer attached. Preserved verbatim
57    /// for forward compatibility.
58    pub extra_columns: Vec<String>,
59}
60
61impl HandlerRecommendationEntry {
62    /// Construct an entry pointing at `handler` with no relay hint
63    /// and no platform marker.
64    #[must_use]
65    pub const fn new(handler: Coordinate) -> Self {
66        Self {
67            handler,
68            relay_hint: None,
69            platform: None,
70            extra_columns: Vec::new(),
71        }
72    }
73
74    /// Attach a relay hint.
75    #[must_use]
76    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
77        self.relay_hint = Some(relay);
78        self
79    }
80
81    /// Attach a platform marker.
82    #[must_use]
83    pub fn platform(mut self, platform: impl Into<String>) -> Self {
84        self.platform = Some(platform.into());
85        self
86    }
87
88    /// Render as a single `a` tag with the spec's column ordering:
89    /// `["a", "<coordinate>", "<relay>", "<platform>"]`.
90    #[must_use]
91    pub fn to_tag(&self) -> Tag {
92        let mut values: Vec<String> = Vec::with_capacity(4);
93        values.push(self.handler.to_wire());
94        match (&self.relay_hint, &self.platform) {
95            (Some(relay), Some(platform)) => {
96                values.push(relay.as_str().to_owned());
97                values.push(platform.clone());
98            }
99            (Some(relay), None) => values.push(relay.as_str().to_owned()),
100            (None, Some(platform)) => {
101                // Per spec the relay slot stays an empty string so
102                // the platform stays at index 3.
103                values.push(String::new());
104                values.push(platform.clone());
105            }
106            (None, None) => {}
107        }
108        values.extend(self.extra_columns.iter().cloned());
109        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
110        Tag::with(&head, values)
111    }
112
113    /// Parse a single `a` [`Tag`] into an entry.
114    ///
115    /// # Errors
116    ///
117    /// - [`HandlerError::MalformedAddressTag`] when the coordinate
118    ///   column is absent.
119    /// - [`HandlerError::InvalidCoordinate`] for malformed coordinates.
120    /// - [`HandlerError::InvalidRelayUrl`] when the relay hint is
121    ///   non-empty and fails to parse.
122    pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
123        let coord_str = tag.get(1).ok_or(HandlerError::MalformedAddressTag)?;
124        let handler = Coordinate::parse(coord_str)?;
125        let relay_hint = match tag.get(2) {
126            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
127            _ => None,
128        };
129        let platform = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
130        let extra_columns: Vec<String> =
131            tag.values().iter().skip(4).map(ToOwned::to_owned).collect();
132        Ok(Self {
133            handler,
134            relay_hint,
135            platform,
136            extra_columns,
137        })
138    }
139}
140
141/// Typed bundle for a `kind: 31989` recommendation event.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct HandlerRecommendation {
144    /// Kind being recommended (encodes as the `d` tag value).
145    pub recommended_kind: Kind,
146    /// Recommendation rows in the order the producer pinned them.
147    pub entries: Vec<HandlerRecommendationEntry>,
148    /// Forward-compatible passthrough for unknown tags.
149    pub extra_tags: Vec<Tag>,
150}
151
152impl HandlerRecommendation {
153    /// Construct an empty recommendation for `kind`.
154    #[must_use]
155    pub const fn new(recommended_kind: Kind) -> Self {
156        Self {
157            recommended_kind,
158            entries: Vec::new(),
159            extra_tags: Vec::new(),
160        }
161    }
162
163    /// Append a recommendation entry.
164    #[must_use]
165    pub fn entry(mut self, entry: HandlerRecommendationEntry) -> Self {
166        self.entries.push(entry);
167        self
168    }
169
170    /// Build the addressable coordinate for this recommendation.
171    #[must_use]
172    pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
173        Coordinate::new(
174            KIND_APP_RECOMMENDATION,
175            author,
176            self.recommended_kind.as_u16().to_string(),
177        )
178    }
179
180    /// Parse a `kind: 31989` event back into a typed bundle.
181    ///
182    /// # Errors
183    ///
184    /// - [`HandlerError::WrongKind`] for any other kind.
185    /// - [`HandlerError::MissingIdentifier`] when the `d` tag is
186    ///   absent.
187    /// - [`HandlerError::InvalidRecommendedKind`] when the `d` value
188    ///   is not a `u16`.
189    /// - Per-entry parser errors propagate as-is.
190    pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
191        if event.kind != KIND_APP_RECOMMENDATION {
192            return Err(HandlerError::WrongKind(event.kind));
193        }
194        let d = d_value(&event.tags).ok_or(HandlerError::MissingIdentifier)?;
195        let recommended_kind: Kind = d
196            .parse::<u16>()
197            .map(Kind::from)
198            .map_err(|_| HandlerError::InvalidRecommendedKind(d.to_owned()))?;
199        let mut entries: Vec<HandlerRecommendationEntry> = Vec::new();
200        let mut extra_tags: Vec<Tag> = Vec::new();
201        for tag in &event.tags {
202            match tag.kind() {
203                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
204                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
205                    entries.push(HandlerRecommendationEntry::from_tag(tag)?);
206                }
207                _ => extra_tags.push(tag.clone()),
208            }
209        }
210        Ok(Self {
211            recommended_kind,
212            entries,
213            extra_tags,
214        })
215    }
216}
217
218/// One entry-point exposed by a handler.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct HandlerPlatformEntry {
221    /// Platform name (`web`, `ios`, `android`, custom). Opaque to us.
222    pub platform: String,
223    /// URL or URI template. May contain `<bech32>` placeholders that
224    /// the client must substitute with a NIP-19 entity.
225    pub url_template: String,
226    /// Optional NIP-19 entity-type hint such as `nevent`, `nprofile`,
227    /// `naddr`. `None` matches the spec's "generic" handler shape.
228    pub entity: Option<String>,
229}
230
231impl HandlerPlatformEntry {
232    /// Construct an entry with no entity hint.
233    #[must_use]
234    pub fn new(platform: impl Into<String>, url_template: impl Into<String>) -> Self {
235        Self {
236            platform: platform.into(),
237            url_template: url_template.into(),
238            entity: None,
239        }
240    }
241
242    /// Attach a NIP-19 entity-type hint (`nevent`, `nprofile`, …).
243    #[must_use]
244    pub fn entity(mut self, entity: impl Into<String>) -> Self {
245        self.entity = Some(entity.into());
246        self
247    }
248
249    /// Render as a [`Tag`].
250    #[must_use]
251    pub fn to_tag(&self) -> Tag {
252        let head = TagKind::from_wire(&self.platform);
253        self.entity.as_ref().map_or_else(
254            || Tag::with(&head, [self.url_template.clone()]),
255            |entity| Tag::with(&head, [self.url_template.clone(), entity.clone()]),
256        )
257    }
258
259    /// Parse a tag emitted by a handler. The tag's head is the
260    /// platform name; column 1 is the URL template; column 2, when
261    /// present, is the entity hint.
262    fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
263        let platform = tag.name().to_owned();
264        let url_template = tag
265            .get(1)
266            .ok_or(HandlerError::MalformedHandlerPlatform)?
267            .to_owned();
268        let entity = tag.get(2).filter(|s| !s.is_empty()).map(str::to_owned);
269        Ok(Self {
270            platform,
271            url_template,
272            entity,
273        })
274    }
275}
276
277/// Typed bundle for a `kind: 31990` handler event.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct HandlerInformation {
280    /// `d`-tag value — handler identifier (free-form).
281    pub identifier: String,
282    /// `.content` — optional stringified `kind: 0`-shaped JSON.
283    pub content: String,
284    /// Supported event kinds (`k` tags).
285    pub supported_kinds: Vec<Kind>,
286    /// Platform-specific entry points.
287    pub platforms: Vec<HandlerPlatformEntry>,
288    /// Forward-compatible passthrough for unknown tags.
289    pub extra_tags: Vec<Tag>,
290}
291
292impl HandlerInformation {
293    /// Construct an empty handler bundle bound to `identifier`.
294    #[must_use]
295    pub fn new(identifier: impl Into<String>) -> Self {
296        Self {
297            identifier: identifier.into(),
298            content: String::new(),
299            supported_kinds: Vec::new(),
300            platforms: Vec::new(),
301            extra_tags: Vec::new(),
302        }
303    }
304
305    /// Replace [`Self::content`].
306    #[must_use]
307    pub fn content(mut self, content: impl Into<String>) -> Self {
308        self.content = content.into();
309        self
310    }
311
312    /// Append a supported event kind.
313    #[must_use]
314    pub fn kind(mut self, kind: Kind) -> Self {
315        self.supported_kinds.push(kind);
316        self
317    }
318
319    /// Append a platform entry.
320    #[must_use]
321    pub fn platform(mut self, entry: HandlerPlatformEntry) -> Self {
322        self.platforms.push(entry);
323        self
324    }
325
326    /// Build the handler's addressable coordinate.
327    #[must_use]
328    pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
329        Coordinate::new(KIND_APP_HANDLER, author, self.identifier.clone())
330    }
331
332    /// Parse a `kind: 31990` event back into a typed bundle.
333    ///
334    /// # Errors
335    ///
336    /// - [`HandlerError::WrongKind`] for any other kind.
337    /// - [`HandlerError::MissingIdentifier`] when the `d` tag is
338    ///   absent.
339    /// - [`HandlerError::InvalidKind`] when a `k` tag has a value
340    ///   that is not a `u16`.
341    pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
342        if event.kind != KIND_APP_HANDLER {
343            return Err(HandlerError::WrongKind(event.kind));
344        }
345        let identifier = d_value(&event.tags)
346            .ok_or(HandlerError::MissingIdentifier)?
347            .to_owned();
348        let mut supported_kinds: Vec<Kind> = Vec::new();
349        let mut platforms: Vec<HandlerPlatformEntry> = Vec::new();
350        let mut extra_tags: Vec<Tag> = Vec::new();
351        for tag in &event.tags {
352            match tag.kind() {
353                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
354                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::K => {
355                    let raw = tag.get(1).ok_or(HandlerError::MalformedKindTag)?;
356                    let kind = raw
357                        .parse::<u16>()
358                        .map(Kind::from)
359                        .map_err(|_| HandlerError::InvalidKind(raw.to_owned()))?;
360                    supported_kinds.push(kind);
361                }
362                TagKind::Custom(_) => match HandlerPlatformEntry::from_tag(tag) {
363                    Ok(entry) => platforms.push(entry),
364                    Err(_) => extra_tags.push(tag.clone()),
365                },
366                _ => extra_tags.push(tag.clone()),
367            }
368        }
369        Ok(Self {
370            identifier,
371            content: event.content.clone(),
372            supported_kinds,
373            platforms,
374            extra_tags,
375        })
376    }
377}
378
379/// `client` tag — identifies the publishing application.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct ClientTag {
382    /// Human-readable client name.
383    pub name: String,
384    /// Optional handler coordinate (`kind: 31990` event).
385    pub handler: Option<Coordinate>,
386    /// Optional relay hint.
387    pub relay_hint: Option<RelayUrl>,
388}
389
390impl ClientTag {
391    /// Construct a name-only client tag.
392    #[must_use]
393    pub fn new(name: impl Into<String>) -> Self {
394        Self {
395            name: name.into(),
396            handler: None,
397            relay_hint: None,
398        }
399    }
400
401    /// Attach a handler coordinate.
402    #[must_use]
403    pub fn handler(mut self, handler: Coordinate) -> Self {
404        self.handler = Some(handler);
405        self
406    }
407
408    /// Attach a relay hint.
409    #[must_use]
410    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
411        self.relay_hint = Some(relay);
412        self
413    }
414
415    /// Render as a [`Tag`].
416    #[must_use]
417    pub fn to_tag(&self) -> Tag {
418        let mut values: Vec<String> = Vec::with_capacity(4);
419        values.push(self.name.clone());
420        match (&self.handler, &self.relay_hint) {
421            (Some(coord), Some(relay)) => {
422                values.push(coord.to_wire());
423                values.push(relay.as_str().to_owned());
424            }
425            (Some(coord), None) => values.push(coord.to_wire()),
426            (None, Some(relay)) => {
427                values.push(String::new());
428                values.push(relay.as_str().to_owned());
429            }
430            (None, None) => {}
431        }
432        Tag::with(&TagKind::from_wire(CLIENT_TAG), values)
433    }
434
435    /// Parse a `client` [`Tag`].
436    ///
437    /// # Errors
438    ///
439    /// - [`HandlerError::WrongTag`] when the tag's head is not
440    ///   `client`.
441    /// - [`HandlerError::MalformedClientTag`] when the name column
442    ///   is absent.
443    /// - Coordinate / relay parsing errors propagate.
444    pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
445        if tag.name() != CLIENT_TAG {
446            return Err(HandlerError::WrongTag);
447        }
448        let name = tag
449            .get(1)
450            .ok_or(HandlerError::MalformedClientTag)?
451            .to_owned();
452        let handler = match tag.get(2) {
453            Some(s) if !s.is_empty() => Some(Coordinate::parse(s)?),
454            _ => None,
455        };
456        let relay_hint = match tag.get(3) {
457            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
458            _ => None,
459        };
460        Ok(Self {
461            name,
462            handler,
463            relay_hint,
464        })
465    }
466}
467
468impl Tag {
469    /// Build a NIP-89 `client` tag.
470    #[must_use]
471    pub fn client(client: &ClientTag) -> Self {
472        client.to_tag()
473    }
474}
475
476/// Errors raised by NIP-89 parsers.
477#[derive(Debug, Error)]
478#[non_exhaustive]
479pub enum HandlerError {
480    /// Event kind did not match the expected NIP-89 kind.
481    #[error("unexpected kind for NIP-89 event: {}", .0.as_u16())]
482    WrongKind(Kind),
483    /// Tag head did not match the expected NIP-89 tag.
484    #[error("unexpected tag for NIP-89")]
485    WrongTag,
486    /// `d` tag is absent.
487    #[error("NIP-89 event must carry a `d` tag")]
488    MissingIdentifier,
489    /// `d`-tag value on a `kind: 31989` event is not a decimal `u16`.
490    #[error("recommendation `d` tag must be a `u16` kind: `{0}`")]
491    InvalidRecommendedKind(String),
492    /// `k` tag value on a `kind: 31990` event is not a decimal `u16`.
493    #[error("handler `k` tag must be a `u16` kind: `{0}`")]
494    InvalidKind(String),
495    /// `k` tag column 1 is absent.
496    #[error("`k` handler tag missing kind value")]
497    MalformedKindTag,
498    /// `a` tag column 1 is absent.
499    #[error("`a` recommendation tag missing handler coordinate")]
500    MalformedAddressTag,
501    /// `client` tag column 1 is absent.
502    #[error("`client` tag missing name column")]
503    MalformedClientTag,
504    /// Handler platform tag missing URL template.
505    #[error("handler platform tag missing URL template")]
506    MalformedHandlerPlatform,
507    /// Coordinate failed to parse.
508    #[error(transparent)]
509    InvalidCoordinate(#[from] CoordinateError),
510    /// Relay URL failed to parse.
511    #[error(transparent)]
512    InvalidRelayUrl(#[from] RelayUrlError),
513}
514
515fn d_value(tags: &Tags) -> Option<&str> {
516    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
517    tags.find_first(&head).and_then(|tag| tag.get(1))
518}
519
520impl EventBuilder {
521    /// Author a NIP-89 `kind: 31989` recommendation event.
522    #[must_use]
523    pub fn handler_recommendation(rec: &HandlerRecommendation) -> Self {
524        let mut builder = Self::new(KIND_APP_RECOMMENDATION, "");
525        builder = builder.tag(Tag::d(rec.recommended_kind.as_u16().to_string()));
526        for entry in &rec.entries {
527            builder = builder.tag(entry.to_tag());
528        }
529        for tag in &rec.extra_tags {
530            builder = builder.tag(tag.clone());
531        }
532        builder
533    }
534
535    /// Author a NIP-89 `kind: 31990` handler event.
536    #[must_use]
537    pub fn handler_information(handler: &HandlerInformation) -> Self {
538        let mut builder = Self::new(KIND_APP_HANDLER, handler.content.clone());
539        builder = builder.tag(Tag::d(&handler.identifier));
540        for kind in &handler.supported_kinds {
541            builder = builder.tag(Tag::k(*kind));
542        }
543        for entry in &handler.platforms {
544            builder = builder.tag(entry.to_tag());
545        }
546        for tag in &handler.extra_tags {
547            builder = builder.tag(tag.clone());
548        }
549        builder
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::Keys;
557
558    fn keys() -> Keys {
559        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
560    }
561
562    fn relay() -> RelayUrl {
563        RelayUrl::parse("wss://relay.example/").unwrap()
564    }
565
566    fn coord(kind: u16, identifier: &str) -> Coordinate {
567        Coordinate::new(Kind::new(kind), *keys().public_key(), identifier.to_owned())
568    }
569
570    #[test]
571    fn recommendation_round_trip() {
572        let rec = HandlerRecommendation::new(Kind::new(31_337))
573            .entry(
574                HandlerRecommendationEntry::new(coord(31_990, "abcd"))
575                    .relay_hint(relay())
576                    .platform("web"),
577            )
578            .entry(HandlerRecommendationEntry::new(coord(31_990, "ios-bundle")).platform("ios"));
579        let event = EventBuilder::handler_recommendation(&rec)
580            .sign_with_keys(&keys())
581            .unwrap();
582        let parsed = HandlerRecommendation::from_event(&event).unwrap();
583        assert_eq!(parsed, rec);
584    }
585
586    #[test]
587    fn recommendation_rejects_wrong_kind() {
588        let event = EventBuilder::text_note("nope")
589            .sign_with_keys(&keys())
590            .unwrap();
591        assert!(matches!(
592            HandlerRecommendation::from_event(&event),
593            Err(HandlerError::WrongKind(_))
594        ));
595    }
596
597    #[test]
598    fn recommendation_rejects_missing_identifier() {
599        let event = EventBuilder::new(KIND_APP_RECOMMENDATION, "")
600            .sign_with_keys(&keys())
601            .unwrap();
602        assert!(matches!(
603            HandlerRecommendation::from_event(&event),
604            Err(HandlerError::MissingIdentifier)
605        ));
606    }
607
608    #[test]
609    fn handler_round_trip_with_platforms() {
610        let handler = HandlerInformation::new("handler-id-1")
611            .content(r#"{"name":"Demo"}"#)
612            .kind(Kind::new(1))
613            .kind(Kind::new(30_023))
614            .platform(
615                HandlerPlatformEntry::new("web", "https://demo.example/a/<bech32>")
616                    .entity("nevent"),
617            )
618            .platform(HandlerPlatformEntry::new("ios", "demo://a/<bech32>"));
619        let event = EventBuilder::handler_information(&handler)
620            .sign_with_keys(&keys())
621            .unwrap();
622        let parsed = HandlerInformation::from_event(&event).unwrap();
623        assert_eq!(parsed, handler);
624    }
625
626    #[test]
627    fn handler_rejects_wrong_kind() {
628        let event = EventBuilder::text_note("nope")
629            .sign_with_keys(&keys())
630            .unwrap();
631        assert!(matches!(
632            HandlerInformation::from_event(&event),
633            Err(HandlerError::WrongKind(_))
634        ));
635    }
636
637    #[test]
638    fn handler_rejects_invalid_kind_tag() {
639        let event = EventBuilder::new(KIND_APP_HANDLER, "")
640            .tag(Tag::d("h-1"))
641            .tag(Tag::with(
642                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::K)),
643                ["not-a-number"],
644            ))
645            .sign_with_keys(&keys())
646            .unwrap();
647        assert!(matches!(
648            HandlerInformation::from_event(&event),
649            Err(HandlerError::InvalidKind(_))
650        ));
651    }
652
653    #[test]
654    fn client_tag_round_trip() {
655        let client = ClientTag::new("My Client")
656            .handler(coord(31_990, "app-id"))
657            .relay_hint(relay());
658        let tag = client.to_tag();
659        assert_eq!(tag.name(), CLIENT_TAG);
660        let parsed = ClientTag::from_tag(&tag).unwrap();
661        assert_eq!(parsed, client);
662    }
663
664    #[test]
665    fn client_tag_name_only() {
666        let client = ClientTag::new("Bare Client");
667        let tag = client.to_tag();
668        let parsed = ClientTag::from_tag(&tag).unwrap();
669        assert_eq!(parsed, client);
670    }
671
672    #[test]
673    fn client_tag_rejects_wrong_head() {
674        let tag = Tag::title("not a client tag");
675        assert!(matches!(
676            ClientTag::from_tag(&tag),
677            Err(HandlerError::WrongTag)
678        ));
679    }
680}