Skip to main content

nula_core/nips/
nip32.rs

1//! [NIP-32] Labeling.
2//!
3//! Labels attach short, vocabulary-scoped strings to nostr targets.
4//! The spec defines two indexable tags and one new event kind:
5//!
6//! - `L` — label *namespace* (e.g. `ISO-639-1`, `com.example.ontology`,
7//!   the reserved `ugc` for user-generated content, or the
8//!   `#`-prefixed form that re-uses a standard NIP tag value).
9//! - `l` — label *value*, optionally carrying a mark that points back
10//!   to the namespace. If no mark is provided the `ugc` namespace is
11//!   implied per spec.
12//! - `kind: 1985` — dedicated label event that targets one or more
13//!   `e` / `p` / `a` / `r` / `t` columns. The body's `.content` is the
14//!   human-readable rationale.
15//!
16//! Self-reporting: any non-1985 event MAY also carry `L`/`l` tags to
17//! tag *itself*. We surface that via [`labels_from_tags`] so callers
18//! can read the namespace/value pairs without duplicating the parser.
19//!
20//! # Forward compatibility
21//!
22//! - Unknown target columns are preserved through [`Label::extra_tags`]
23//!   so producers cannot strip metadata accidentally.
24//! - Labels without a mark are accepted on the wire and surfaced with
25//!   namespace = [`UGC_NAMESPACE`] per spec §"Label Tag".
26//! - The label namespace is treated as opaque — no validation beyond
27//!   non-emptiness — so new ontologies work without a crate bump.
28//!
29//! [NIP-32]: https://github.com/nostr-protocol/nips/blob/master/32.md
30
31use thiserror::Error;
32
33use crate::event::{
34    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
35    SingleLetterTag, Tag, TagKind, Tags,
36};
37use crate::key::{PublicKey, PublicKeyError};
38use crate::types::{RelayUrl, RelayUrlError, Url, UrlError};
39
40/// `kind: 1985` — labeling event.
41pub const KIND_LABEL: Kind = Kind::LABEL;
42
43/// Reserved namespace for user-generated content (spec §"Label
44/// Namespace Tag").
45pub const UGC_NAMESPACE: &str = "ugc";
46
47/// One namespace/value pair, the elemental unit of NIP-32.
48///
49/// `namespace` is the `L` tag value (or [`UGC_NAMESPACE`] when the
50/// label tag had no mark). `value` is the `l` tag's first column.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct LabelTerm {
53    /// `L` namespace. Always non-empty when surfaced from
54    /// [`Label::from_event`] / [`labels_from_tags`].
55    pub namespace: String,
56    /// `l` value.
57    pub value: String,
58}
59
60impl LabelTerm {
61    /// Construct a term in `namespace` with `value`.
62    #[must_use]
63    pub fn new(namespace: impl Into<String>, value: impl Into<String>) -> Self {
64        Self {
65            namespace: namespace.into(),
66            value: value.into(),
67        }
68    }
69
70    /// Convenience constructor for the spec's reserved
71    /// [`UGC_NAMESPACE`].
72    #[must_use]
73    pub fn ugc(value: impl Into<String>) -> Self {
74        Self::new(UGC_NAMESPACE, value)
75    }
76
77    /// True if this term lives in the reserved [`UGC_NAMESPACE`].
78    #[must_use]
79    pub fn is_ugc(&self) -> bool {
80        self.namespace == UGC_NAMESPACE
81    }
82}
83
84/// The object a label points at. The spec lists five wire columns;
85/// we map them to typed variants and preserve relay hints when the
86/// spec allows them.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum LabelTarget {
89    /// `e` tag — labels a specific event by id.
90    Event {
91        /// Target event id.
92        id: EventId,
93        /// Optional relay hint (spec §"Label Target": SHOULD be
94        /// included for `e`/`p`).
95        relay_hint: Option<RelayUrl>,
96    },
97    /// `p` tag — labels a profile by pubkey.
98    Pubkey {
99        /// Target pubkey.
100        pubkey: PublicKey,
101        /// Optional relay hint.
102        relay_hint: Option<RelayUrl>,
103    },
104    /// `a` tag — labels an addressable event by coordinate.
105    Address {
106        /// Target coordinate.
107        coordinate: Coordinate,
108        /// Optional relay hint.
109        relay_hint: Option<RelayUrl>,
110    },
111    /// `r` tag — labels an external URL.
112    Url(Url),
113    /// `t` tag — labels a hashtag/topic. Lower-cased by [`Tag::t`].
114    Topic(String),
115}
116
117impl LabelTarget {
118    /// Render the target as a [`Tag`].
119    #[must_use]
120    pub fn to_tag(&self) -> Tag {
121        match self {
122            Self::Event { id, relay_hint } => relay_hint
123                .as_ref()
124                .map_or_else(|| Tag::e(*id), |url| Tag::e_with_relay(*id, url)),
125            Self::Pubkey { pubkey, relay_hint } => relay_hint
126                .as_ref()
127                .map_or_else(|| Tag::p(*pubkey), |url| Tag::p_with_relay(*pubkey, url)),
128            Self::Address {
129                coordinate,
130                relay_hint,
131            } => relay_hint.as_ref().map_or_else(
132                || Tag::a(coordinate),
133                |url| Tag::a_with_relay(coordinate, url),
134            ),
135            Self::Url(url) => Tag::r(url),
136            Self::Topic(topic) => Tag::t(topic),
137        }
138    }
139}
140
141/// Typed bundle for a NIP-32 `kind: 1985` label event.
142///
143/// At least one term and one target are RECOMMENDED but not strictly
144/// required by the spec — parsers accept what is on the wire.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct Label {
147    /// Namespace/value pairs. Order is preserved across round-trips
148    /// so producers that pin a stable order keep it.
149    pub terms: Vec<LabelTerm>,
150    /// Targets being labeled.
151    pub targets: Vec<LabelTarget>,
152    /// `.content` — long-form rationale (often empty).
153    pub content: String,
154    /// Tags the producer attached that we did not recognise.
155    /// Round-tripped verbatim for forward compatibility.
156    pub extra_tags: Vec<Tag>,
157}
158
159impl Label {
160    /// Construct an empty label bundle.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Append a namespace/value pair.
167    #[must_use]
168    pub fn term(mut self, term: LabelTerm) -> Self {
169        self.terms.push(term);
170        self
171    }
172
173    /// Append a target.
174    #[must_use]
175    pub fn target(mut self, target: LabelTarget) -> Self {
176        self.targets.push(target);
177        self
178    }
179
180    /// Replace the `.content` rationale.
181    #[must_use]
182    pub fn content(mut self, content: impl Into<String>) -> Self {
183        self.content = content.into();
184        self
185    }
186
187    /// Parse a `kind: 1985` event into a typed bundle.
188    ///
189    /// # Errors
190    ///
191    /// - [`LabelError::WrongKind`] when the event is not `kind: 1985`.
192    /// - [`LabelError::MalformedNamespace`] /
193    ///   [`LabelError::MalformedValue`] when a tag is missing its
194    ///   value column.
195    /// - [`LabelError::InvalidEventId`] /
196    ///   [`LabelError::InvalidPublicKey`] /
197    ///   [`LabelError::InvalidCoordinate`] /
198    ///   [`LabelError::InvalidUrl`] /
199    ///   [`LabelError::InvalidRelayUrl`] for malformed targets.
200    pub fn from_event(event: &Event) -> Result<Self, LabelError> {
201        if event.kind != KIND_LABEL {
202            return Err(LabelError::WrongKind(event.kind));
203        }
204        let terms = labels_from_tags(&event.tags)?;
205        let mut targets: Vec<LabelTarget> = Vec::new();
206        let mut extra_tags: Vec<Tag> = Vec::new();
207        for tag in &event.tags {
208            match tag.kind() {
209                TagKind::SingleLetter(s) if s.uppercase && s.character == Alphabet::L => {
210                    // Namespace tag handled by `labels_from_tags`.
211                }
212                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::L => {
213                    // Value tag handled by `labels_from_tags`.
214                }
215                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
216                    targets.push(parse_event_target(tag)?);
217                }
218                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
219                    targets.push(parse_pubkey_target(tag)?);
220                }
221                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
222                    targets.push(parse_address_target(tag)?);
223                }
224                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::R => {
225                    targets.push(parse_url_target(tag)?);
226                }
227                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
228                    targets.push(parse_topic_target(tag)?);
229                }
230                _ => extra_tags.push(tag.clone()),
231            }
232        }
233        Ok(Self {
234            terms,
235            targets,
236            content: event.content.clone(),
237            extra_tags,
238        })
239    }
240}
241
242/// Read self-reported `L`/`l` tags off any event's tag list. Per
243/// spec §"Self-Reporting" this is the same parser used by the
244/// `kind: 1985` reader but applicable to any kind.
245///
246/// `l` tags without a mark surface in [`UGC_NAMESPACE`]; tags with a
247/// mark that does not match any `L` tag in the same event are still
248/// surfaced verbatim because some publishers omit the `L` column on
249/// purpose.
250///
251/// # Errors
252///
253/// Returns [`LabelError::MalformedValue`] when an `l` tag has no
254/// value column.
255pub fn labels_from_tags(tags: &Tags) -> Result<Vec<LabelTerm>, LabelError> {
256    let mut terms: Vec<LabelTerm> = Vec::new();
257    for tag in tags {
258        let TagKind::SingleLetter(letter) = tag.kind() else {
259            continue;
260        };
261        if letter.character != Alphabet::L || letter.uppercase {
262            continue;
263        }
264        let value = tag.get(1).ok_or(LabelError::MalformedValue)?.to_owned();
265        let namespace = tag
266            .get(2)
267            .filter(|ns| !ns.is_empty())
268            .map_or_else(|| UGC_NAMESPACE.to_owned(), str::to_owned);
269        terms.push(LabelTerm { namespace, value });
270    }
271    Ok(terms)
272}
273
274/// Render a [`LabelTerm`] into the `L`/`l` tag pair the spec
275/// requires. The namespace tag comes first to match the example
276/// ordering in NIP-32.
277#[must_use]
278pub fn term_to_tags(term: &LabelTerm) -> [Tag; 2] {
279    [
280        Tag::with(
281            &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::L)),
282            [term.namespace.clone()],
283        ),
284        Tag::with(
285            &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::L)),
286            [term.value.clone(), term.namespace.clone()],
287        ),
288    ]
289}
290
291/// Errors raised by [`Label::from_event`] and [`labels_from_tags`].
292#[derive(Debug, Error)]
293#[non_exhaustive]
294pub enum LabelError {
295    /// The event was not `kind: 1985`.
296    #[error("expected kind 1985 (label), got kind {}", .0.as_u16())]
297    WrongKind(Kind),
298    /// An `L` tag had no namespace value.
299    #[error("`L` namespace tag missing namespace value")]
300    MalformedNamespace,
301    /// An `l` tag had no value column.
302    #[error("`l` label tag missing value")]
303    MalformedValue,
304    /// An `e` target tag had no event id column.
305    #[error("`e` target tag missing event id")]
306    MalformedEventTarget,
307    /// A `p` target tag had no pubkey column.
308    #[error("`p` target tag missing pubkey")]
309    MalformedPubkeyTarget,
310    /// An `a` target tag had no coordinate column.
311    #[error("`a` target tag missing coordinate")]
312    MalformedAddressTarget,
313    /// An `r` target tag had no URL column.
314    #[error("`r` target tag missing URL")]
315    MalformedUrlTarget,
316    /// A `t` target tag had no topic column.
317    #[error("`t` target tag missing topic")]
318    MalformedTopicTarget,
319    /// The `e` event id could not be parsed.
320    #[error(transparent)]
321    InvalidEventId(#[from] EventIdError),
322    /// The `p` pubkey could not be parsed.
323    #[error(transparent)]
324    InvalidPublicKey(#[from] PublicKeyError),
325    /// The `a` coordinate could not be parsed.
326    #[error(transparent)]
327    InvalidCoordinate(#[from] CoordinateError),
328    /// The `r` URL could not be parsed.
329    #[error(transparent)]
330    InvalidUrl(#[from] UrlError),
331    /// A relay hint URL could not be parsed.
332    #[error(transparent)]
333    InvalidRelayUrl(#[from] RelayUrlError),
334}
335
336fn parse_event_target(tag: &Tag) -> Result<LabelTarget, LabelError> {
337    let id_hex = tag.get(1).ok_or(LabelError::MalformedEventTarget)?;
338    let id = EventId::parse(id_hex)?;
339    let relay_hint = parse_optional_relay(tag.get(2))?;
340    Ok(LabelTarget::Event { id, relay_hint })
341}
342
343fn parse_pubkey_target(tag: &Tag) -> Result<LabelTarget, LabelError> {
344    let pk_hex = tag.get(1).ok_or(LabelError::MalformedPubkeyTarget)?;
345    let pubkey = PublicKey::parse(pk_hex)?;
346    let relay_hint = parse_optional_relay(tag.get(2))?;
347    Ok(LabelTarget::Pubkey { pubkey, relay_hint })
348}
349
350fn parse_address_target(tag: &Tag) -> Result<LabelTarget, LabelError> {
351    let coord_str = tag.get(1).ok_or(LabelError::MalformedAddressTarget)?;
352    let coordinate = Coordinate::parse(coord_str)?;
353    let relay_hint = parse_optional_relay(tag.get(2))?;
354    Ok(LabelTarget::Address {
355        coordinate,
356        relay_hint,
357    })
358}
359
360fn parse_url_target(tag: &Tag) -> Result<LabelTarget, LabelError> {
361    let url_str = tag.get(1).ok_or(LabelError::MalformedUrlTarget)?;
362    let url = Url::parse(url_str)?;
363    Ok(LabelTarget::Url(url))
364}
365
366fn parse_topic_target(tag: &Tag) -> Result<LabelTarget, LabelError> {
367    let topic = tag
368        .get(1)
369        .ok_or(LabelError::MalformedTopicTarget)?
370        .to_owned();
371    Ok(LabelTarget::Topic(topic))
372}
373
374fn parse_optional_relay(value: Option<&str>) -> Result<Option<RelayUrl>, LabelError> {
375    match value {
376        Some(s) if !s.is_empty() => Ok(Some(RelayUrl::parse(s)?)),
377        _ => Ok(None),
378    }
379}
380
381impl EventBuilder {
382    /// Author a NIP-32 `kind: 1985` label event.
383    ///
384    /// Tag layout follows the spec's example order: namespaces first
385    /// (paired with their `l` value tag), then targets, then any
386    /// caller-provided extras.
387    #[must_use]
388    pub fn label(label: &Label) -> Self {
389        let mut builder = Self::new(KIND_LABEL, label.content.clone());
390        for term in &label.terms {
391            for tag in term_to_tags(term) {
392                builder = builder.tag(tag);
393            }
394        }
395        for target in &label.targets {
396            builder = builder.tag(target.to_tag());
397        }
398        for tag in &label.extra_tags {
399            builder = builder.tag(tag.clone());
400        }
401        builder
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::Keys;
409
410    fn keys() -> Keys {
411        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
412    }
413
414    fn relay() -> RelayUrl {
415        RelayUrl::parse("wss://relay.example/").unwrap()
416    }
417
418    #[test]
419    fn round_trip_topic_label() {
420        let label = Label::new()
421            .term(LabelTerm::new("ISO-639-1", "en"))
422            .target(LabelTarget::Topic("nostr".to_owned()));
423        let event = EventBuilder::label(&label).sign_with_keys(&keys()).unwrap();
424        assert_eq!(event.kind, KIND_LABEL);
425        let parsed = Label::from_event(&event).unwrap();
426        assert_eq!(parsed, label);
427    }
428
429    #[test]
430    fn round_trip_pubkey_label_with_relay() {
431        let target = LabelTarget::Pubkey {
432            pubkey: *keys().public_key(),
433            relay_hint: Some(relay()),
434        };
435        let label = Label::new()
436            .term(LabelTerm::new("com.example.ontology", "VI-hum"))
437            .target(target);
438        let event = EventBuilder::label(&label).sign_with_keys(&keys()).unwrap();
439        let parsed = Label::from_event(&event).unwrap();
440        assert_eq!(parsed, label);
441    }
442
443    #[test]
444    fn round_trip_multiple_terms_and_targets() {
445        let id = EventId::from_byte_array([0x11; 32]);
446        let coord = Coordinate::new(Kind::new(30_023), *keys().public_key(), "post-1".to_owned());
447        let label = Label::new()
448            .term(LabelTerm::new("license", "MIT"))
449            .term(LabelTerm::new("nip28.moderation", "approve"))
450            .target(LabelTarget::Event {
451                id,
452                relay_hint: Some(relay()),
453            })
454            .target(LabelTarget::Address {
455                coordinate: coord,
456                relay_hint: None,
457            })
458            .target(LabelTarget::Url(Url::parse("https://example.com").unwrap()))
459            .content("ok");
460        let event = EventBuilder::label(&label).sign_with_keys(&keys()).unwrap();
461        let parsed = Label::from_event(&event).unwrap();
462        assert_eq!(parsed, label);
463    }
464
465    #[test]
466    fn self_reporting_labels_are_readable_from_kind_one_event() {
467        let event = EventBuilder::text_note("It's beautiful here in Milan!")
468            .tag(Tag::with(
469                &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::L)),
470                ["ISO-3166-2"],
471            ))
472            .tag(Tag::with(
473                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::L)),
474                ["IT-MI", "ISO-3166-2"],
475            ))
476            .sign_with_keys(&keys())
477            .unwrap();
478        let terms = labels_from_tags(&event.tags).unwrap();
479        assert_eq!(terms, vec![LabelTerm::new("ISO-3166-2", "IT-MI")]);
480    }
481
482    #[test]
483    fn unmarked_label_falls_back_to_ugc() {
484        let event = EventBuilder::text_note("note")
485            .tag(Tag::with(
486                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::L)),
487                ["spam"],
488            ))
489            .sign_with_keys(&keys())
490            .unwrap();
491        let terms = labels_from_tags(&event.tags).unwrap();
492        assert_eq!(terms, vec![LabelTerm::ugc("spam")]);
493        assert!(terms[0].is_ugc());
494    }
495
496    #[test]
497    fn wrong_kind_is_rejected() {
498        let event = EventBuilder::text_note("nope")
499            .sign_with_keys(&keys())
500            .unwrap();
501        assert!(matches!(
502            Label::from_event(&event),
503            Err(LabelError::WrongKind(_))
504        ));
505    }
506
507    #[test]
508    fn invalid_event_target_propagates() {
509        let event = EventBuilder::new(KIND_LABEL, "")
510            .tag(Tag::with(
511                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
512                ["not-a-hex"],
513            ))
514            .sign_with_keys(&keys())
515            .unwrap();
516        assert!(matches!(
517            Label::from_event(&event),
518            Err(LabelError::InvalidEventId(_))
519        ));
520    }
521
522    #[test]
523    fn extra_unknown_tags_are_preserved() {
524        let custom = Tag::with(&TagKind::Custom("foo".to_owned()), ["bar"]);
525        let label = Label::new()
526            .term(LabelTerm::new("license", "MIT"))
527            .target(LabelTarget::Topic("rust".to_owned()));
528        let mut event_builder = EventBuilder::label(&label);
529        event_builder = event_builder.tag(custom.clone());
530        let event = event_builder.sign_with_keys(&keys()).unwrap();
531        let parsed = Label::from_event(&event).unwrap();
532        assert_eq!(parsed.extra_tags, vec![custom]);
533    }
534}