Skip to main content

nula_core/
metadata.rs

1//! NIP-01 user metadata (kind `0` event content).
2//!
3//! Per [NIP-01], the content of a `kind: 0` event is a JSON-encoded user
4//! profile. NIP-24 layered additional public-profile fields on top of the
5//! original four (`name`, `about`, `picture`, `nip05`); this struct models
6//! every standardised field and preserves any unknown ones inside
7//! [`Metadata::custom`] so future NIP extensions stay round-trippable.
8//!
9//! [NIP-01]: https://github.com/nostr-protocol/nips/blob/master/01.md
10
11use serde::{Deserialize, Serialize};
12use serde_json::Map as JsonMap;
13use serde_json::Value as JsonValue;
14
15use crate::event::{EventBuilder, Kind};
16use crate::types::Url;
17
18/// User profile metadata published as the content of a `kind: 0` event.
19///
20/// Every field is optional; unknown JSON properties survive a round-trip via
21/// [`Metadata::custom`].
22#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Metadata {
24    /// Short username, e.g. `alice`.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub name: Option<String>,
27    /// Display name (NIP-24).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub display_name: Option<String>,
30    /// Biographical description.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub about: Option<String>,
33    /// Personal web site (NIP-24).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub website: Option<Url>,
36    /// Profile picture URL.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub picture: Option<Url>,
39    /// Banner image URL (NIP-24).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub banner: Option<Url>,
42    /// NIP-05 verification identifier (`alice@example.com`).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub nip05: Option<String>,
45    /// LNURL-pay identifier (legacy LUD-06).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub lud06: Option<String>,
48    /// Lightning Address (LUD-16).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub lud16: Option<String>,
51    /// NIP-24 `bot` flag: `true` if the profile is fully or partially
52    /// automated (chatbots, newsfeeds, AI agents).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub bot: Option<bool>,
55    /// NIP-24 `birthday` object. Every component (`year` / `month` /
56    /// `day`) is individually optional so partial dates (e.g.
57    /// month-and-day only) round-trip cleanly.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub birthday: Option<Birthday>,
60    /// Forward-compatible escape hatch: any property the spec adds (or any
61    /// project-specific custom property) is preserved verbatim here.
62    ///
63    /// Two NIP-24 *deprecated* fields intentionally land here rather
64    /// than in dedicated fields:
65    ///
66    /// - `displayName` (camel-case) — superseded by [`Self::display_name`];
67    /// - `username` — superseded by [`Self::name`].
68    ///
69    /// Access them via [`Self::legacy_display_name`] and
70    /// [`Self::legacy_username`] when a caller needs to migrate an old
71    /// profile without dropping bytes.
72    #[serde(flatten)]
73    pub custom: JsonMap<String, JsonValue>,
74}
75
76/// NIP-24 `birthday` object.
77///
78/// All three components are independently optional: a profile MAY publish
79/// only `month` + `day` to celebrate without revealing the year, only
80/// `year` + `month` for approximate birthdays, or any other subset.
81///
82/// The struct is `Copy` because it carries only small integer fields;
83/// the field types (`u16` for year, `u8` for month/day) reject obvious
84/// out-of-range values at deserialisation time without extra guards.
85#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct Birthday {
87    /// Birth year (e.g. `1990`). Range is not further constrained
88    /// because the spec leaves future-dated profiles to application
89    /// policy.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub year: Option<u16>,
92    /// Birth month (1–12 when present; values outside that range are
93    /// allowed to round-trip but should be rejected at application
94    /// level).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub month: Option<u8>,
97    /// Birth day of month (1–31 when present; application layer is
98    /// responsible for month-specific validation).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub day: Option<u8>,
101}
102
103impl Birthday {
104    /// Construct a fully specified birthday.
105    #[must_use]
106    pub const fn new(year: u16, month: u8, day: u8) -> Self {
107        Self {
108            year: Some(year),
109            month: Some(month),
110            day: Some(day),
111        }
112    }
113
114    /// Construct a month-and-day-only birthday (privacy-preserving form
115    /// used by several real-world clients).
116    #[must_use]
117    pub const fn month_day(month: u8, day: u8) -> Self {
118        Self {
119            year: None,
120            month: Some(month),
121            day: Some(day),
122        }
123    }
124}
125
126impl Metadata {
127    /// Construct an empty profile.
128    #[must_use]
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Set the `name` field.
134    #[must_use]
135    pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
136        self.name = Some(name.into());
137        self
138    }
139
140    /// Set the `display_name` field (NIP-24).
141    #[must_use]
142    pub fn with_display_name<S: Into<String>>(mut self, name: S) -> Self {
143        self.display_name = Some(name.into());
144        self
145    }
146
147    /// Set the `about` field.
148    #[must_use]
149    pub fn with_about<S: Into<String>>(mut self, about: S) -> Self {
150        self.about = Some(about.into());
151        self
152    }
153
154    /// Set the `website` field.
155    #[must_use]
156    pub fn with_website(mut self, website: Url) -> Self {
157        self.website = Some(website);
158        self
159    }
160
161    /// Set the `picture` field.
162    #[must_use]
163    pub fn with_picture(mut self, picture: Url) -> Self {
164        self.picture = Some(picture);
165        self
166    }
167
168    /// Set the `banner` field (NIP-24).
169    #[must_use]
170    pub fn with_banner(mut self, banner: Url) -> Self {
171        self.banner = Some(banner);
172        self
173    }
174
175    /// Set the `nip05` field.
176    #[must_use]
177    pub fn with_nip05<S: Into<String>>(mut self, nip05: S) -> Self {
178        self.nip05 = Some(nip05.into());
179        self
180    }
181
182    /// Set the `lud06` field (legacy LNURL-pay).
183    #[must_use]
184    pub fn with_lud06<S: Into<String>>(mut self, lud06: S) -> Self {
185        self.lud06 = Some(lud06.into());
186        self
187    }
188
189    /// Set the `lud16` field (Lightning Address).
190    #[must_use]
191    pub fn with_lud16<S: Into<String>>(mut self, lud16: S) -> Self {
192        self.lud16 = Some(lud16.into());
193        self
194    }
195
196    /// Set the NIP-24 `bot` flag.
197    #[must_use]
198    pub const fn with_bot(mut self, bot: bool) -> Self {
199        self.bot = Some(bot);
200        self
201    }
202
203    /// Set the NIP-24 `birthday` object.
204    #[must_use]
205    pub const fn with_birthday(mut self, birthday: Birthday) -> Self {
206        self.birthday = Some(birthday);
207        self
208    }
209
210    /// Return the deprecated NIP-24 `displayName` value if the profile
211    /// was produced by an older client. Prefer [`Self::display_name`]
212    /// for every new write path.
213    #[must_use]
214    pub fn legacy_display_name(&self) -> Option<&str> {
215        self.custom.get("displayName").and_then(JsonValue::as_str)
216    }
217
218    /// Return the deprecated NIP-24 `username` value. Prefer
219    /// [`Self::name`] for every new write path.
220    #[must_use]
221    pub fn legacy_username(&self) -> Option<&str> {
222        self.custom.get("username").and_then(JsonValue::as_str)
223    }
224
225    /// Insert a custom JSON property.
226    ///
227    /// Useful for extensions defined by future NIPs that this crate has not
228    /// modelled yet.
229    #[must_use]
230    pub fn with_custom<S, V>(mut self, key: S, value: V) -> Self
231    where
232        S: Into<String>,
233        V: Into<JsonValue>,
234    {
235        self.custom.insert(key.into(), value.into());
236        self
237    }
238
239    /// Render `self` as the JSON string that goes into a `kind: 0` event's
240    /// `content` field.
241    ///
242    /// # Errors
243    ///
244    /// Returns the underlying `serde_json` error if the metadata cannot be
245    /// serialized (in practice impossible for user metadata, but `serde_json`
246    /// keeps the type fallible).
247    pub fn to_event_content(&self) -> Result<String, serde_json::Error> {
248        serde_json::to_string(self)
249    }
250
251    /// Parse the `content` of a `kind: 0` event into a [`Metadata`].
252    ///
253    /// # Errors
254    ///
255    /// Returns a `serde_json` error if `content` is not a JSON object.
256    pub fn from_event_content(content: &str) -> Result<Self, serde_json::Error> {
257        serde_json::from_str(content)
258    }
259}
260
261impl EventBuilder {
262    /// Construct an [`EventBuilder`] for a `kind: 0` profile event whose
263    /// content is the JSON serialization of `metadata`.
264    ///
265    /// # Errors
266    ///
267    /// Returns a `serde_json` error if `metadata` cannot be serialized.
268    pub fn metadata(metadata: &Metadata) -> Result<Self, serde_json::Error> {
269        let content = metadata.to_event_content()?;
270        Ok(Self::new(Kind::METADATA, content))
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::Keys;
278
279    fn keys() -> Keys {
280        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
281    }
282
283    #[test]
284    fn empty_round_trip() {
285        let meta = Metadata::default();
286        let json = meta.to_event_content().unwrap();
287        assert_eq!(json, "{}");
288        let parsed = Metadata::from_event_content(&json).unwrap();
289        assert_eq!(parsed, meta);
290    }
291
292    #[test]
293    fn populated_round_trip() {
294        let meta = Metadata::new()
295            .with_name("alice")
296            .with_display_name("Alice")
297            .with_about("Cypherpunk.")
298            .with_website(Url::parse("https://alice.example").unwrap())
299            .with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
300            .with_banner(Url::parse("https://alice.example/banner.png").unwrap())
301            .with_nip05("alice@alice.example")
302            .with_lud06("LNURL1...")
303            .with_lud16("alice@getalby.com");
304        let json = meta.to_event_content().unwrap();
305        let parsed = Metadata::from_event_content(&json).unwrap();
306        assert_eq!(parsed, meta);
307    }
308
309    #[test]
310    fn custom_fields_survive_round_trip() {
311        let meta = Metadata::new()
312            .with_name("alice")
313            .with_custom("custom_handle", "@alice")
314            .with_custom("x_internal_id", 7);
315        let json = meta.to_event_content().unwrap();
316        let parsed = Metadata::from_event_content(&json).unwrap();
317        assert_eq!(parsed, meta);
318        assert_eq!(
319            parsed.custom.get("custom_handle"),
320            Some(&JsonValue::String("@alice".into()))
321        );
322        assert_eq!(
323            parsed.custom.get("x_internal_id"),
324            Some(&JsonValue::Number(7.into()))
325        );
326        // `bot` is now a first-class field, not a custom escape-hatch.
327        assert!(parsed.custom.get("bot").is_none());
328    }
329
330    #[test]
331    fn unknown_fields_remain_in_custom() {
332        let json = r#"{"name":"alice","future_field":42}"#;
333        let meta = Metadata::from_event_content(json).unwrap();
334        assert_eq!(meta.name.as_deref(), Some("alice"));
335        assert_eq!(
336            meta.custom.get("future_field"),
337            Some(&JsonValue::Number(42.into()))
338        );
339    }
340
341    #[test]
342    fn event_builder_metadata_helper_signs_kind_zero() {
343        let meta = Metadata::new()
344            .with_name("alice")
345            .with_about("Hello, Nostr.");
346        let event = EventBuilder::metadata(&meta)
347            .unwrap()
348            .sign_with_keys(&keys())
349            .unwrap();
350        assert_eq!(event.kind, Kind::METADATA);
351        let parsed = Metadata::from_event_content(&event.content).unwrap();
352        assert_eq!(parsed, meta);
353        event.verify().unwrap();
354    }
355
356    /// Pinned vectors from the NIP-24 §"Extra metadata fields" spec.
357    /// Each fixture is the literal `content` JSON a real-world client
358    /// (Damus, Amethyst, Coracle) ships in a `kind: 0` event. These
359    /// guard against drift in the field-level serde shape across
360    /// future refactors.
361    mod nip24_fixtures {
362        use super::*;
363
364        /// Minimal NIP-01 §user-metadata payload (name + about + picture).
365        /// Round-tripping must produce identical bytes after re-encoding.
366        #[test]
367        fn nip01_minimal_round_trip() {
368            let json = r#"{"name":"alice","about":"cypherpunk","picture":"https://alice.example/pfp.png"}"#;
369            let parsed = Metadata::from_event_content(json).unwrap();
370            assert_eq!(parsed.name.as_deref(), Some("alice"));
371            assert_eq!(parsed.about.as_deref(), Some("cypherpunk"));
372            assert_eq!(
373                parsed.picture.as_ref().map(Url::as_str),
374                Some("https://alice.example/pfp.png"),
375            );
376            // Re-encode and confirm the JSON round-trips field-for-field.
377            let again = Metadata::from_event_content(&parsed.to_event_content().unwrap()).unwrap();
378            assert_eq!(again, parsed);
379        }
380
381        /// Full NIP-24 payload exercising every standardised field.
382        #[test]
383        fn nip24_full_payload_round_trip() {
384            let meta = Metadata::new()
385                .with_name("alice")
386                .with_display_name("Alice the Cypherpunk")
387                .with_about("Building on Nostr.")
388                .with_website(Url::parse("https://alice.example").unwrap())
389                .with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
390                .with_banner(Url::parse("https://alice.example/banner.png").unwrap())
391                .with_nip05("alice@alice.example")
392                .with_lud06("LNURL1DP68GURN8GHJ7AMPD3KX2AR0VEEKZAR0WD5XJTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHKZURF9AKXUATJD3CZ7CT9XGEK2ATWXSHHQH4UQAQE")
393                .with_lud16("alice@getalby.com")
394                .with_bot(false)
395                .with_birthday(Birthday::new(1990, 6, 15));
396
397            let json = meta.to_event_content().unwrap();
398            // Sanity: every standardised field appears verbatim in the
399            // wire form. serde_json sorts Object keys alphabetically, so
400            // for nested objects we only assert the inner numbers, not
401            // the surrounding key order.
402            for needle in [
403                r#""name":"alice""#,
404                r#""display_name":"Alice the Cypherpunk""#,
405                r#""website":"https://alice.example/""#,
406                r#""banner":"https://alice.example/banner.png""#,
407                r#""nip05":"alice@alice.example""#,
408                r#""lud16":"alice@getalby.com""#,
409                r#""bot":false"#,
410                r#""day":15"#,
411                r#""month":6"#,
412                r#""year":1990"#,
413            ] {
414                assert!(
415                    json.contains(needle),
416                    "missing `{needle}` in serialized metadata: {json}",
417                );
418            }
419            assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
420        }
421
422        /// Real-world Coracle-style payload that emits an unknown
423        /// `damus_donation_v2` field. Forward-compat: we round-trip it
424        /// through `custom` without dropping bytes.
425        #[test]
426        fn forward_compat_unknown_fields_round_trip() {
427            let json = r#"{"name":"bob","damus_donation_v2":21,"website":"https://b.example/"}"#;
428            let parsed = Metadata::from_event_content(json).unwrap();
429            assert_eq!(parsed.name.as_deref(), Some("bob"));
430            assert_eq!(
431                parsed.custom.get("damus_donation_v2"),
432                Some(&serde_json::Value::Number(21.into()))
433            );
434            // Re-encoding preserves the unknown field.
435            let again = parsed.to_event_content().unwrap();
436            assert!(again.contains(r#""damus_donation_v2":21"#));
437        }
438
439        /// Privacy-preserving birthday: only month + day. NIP-24
440        /// explicitly allows omitting the year.
441        #[test]
442        fn partial_birthday_round_trip() {
443            let meta = Metadata::new()
444                .with_name("mallory")
445                .with_birthday(Birthday::month_day(4, 1));
446            let json = meta.to_event_content().unwrap();
447            assert!(json.contains(r#""birthday":{"month":4,"day":1}"#));
448            assert!(
449                !json.contains("\"year\""),
450                "omitted year must not appear in the payload: {json}"
451            );
452            assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
453        }
454
455        /// NIP-24 §Deprecated fields: `displayName` / `username` must
456        /// survive a round-trip through `custom` and be reachable via
457        /// the legacy accessors without shadowing the canonical
458        /// `display_name` / `name` fields.
459        #[test]
460        fn deprecated_fields_are_accessible_via_legacy_getters() {
461            let json = r#"{"displayName":"Dave","username":"davey","name":"dave","display_name":"Dave (new)"}"#;
462            let meta = Metadata::from_event_content(json).unwrap();
463
464            assert_eq!(meta.name.as_deref(), Some("dave"));
465            assert_eq!(meta.display_name.as_deref(), Some("Dave (new)"));
466            assert_eq!(meta.legacy_username(), Some("davey"));
467            assert_eq!(meta.legacy_display_name(), Some("Dave"));
468
469            // Bytes stay: re-serialising still includes both legacy keys.
470            let again = meta.to_event_content().unwrap();
471            assert!(again.contains(r#""displayName":"Dave""#));
472            assert!(again.contains(r#""username":"davey""#));
473        }
474    }
475}