lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 023a88f96da7895a10c240704849c79b90e80a34
parent 48a6d37106a2bbcc4b8e9c5f172ea9729ef14852
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 20:19:08 +0000

nostr: seal typed profile publication

- wrap strict authored Profile wire output in an opaque event builder
- expose local signing and typed client publication without raw mutation
- preserve the runtime proof requirement for media upload completion
- cover deterministic kind-0 replacement signing

Diffstat:
Mcrates/nostr/Cargo.toml | 5+++++
Mcrates/nostr/README | 7+++++++
Mcrates/nostr/src/client.rs | 27+++++++++++++++++++++++++++
Mcrates/nostr/src/error.rs | 6++++++
Mcrates/nostr/src/events/metadata.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/nostr/src/lib.rs | 8++++++++
Acrates/nostr/tests/profile_event_builder.rs | 36++++++++++++++++++++++++++++++++++++
7 files changed, 143 insertions(+), 4 deletions(-)

diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -23,6 +23,7 @@ events = [ "dep:radroots_event_codec", "radroots_event/std", "radroots_event/serde", + "radroots_event_codec/serde_json", "radroots_event_codec/std", ] http = ["dep:reqwest"] @@ -58,6 +59,10 @@ name = "post_profile" required-features = ["events"] [[test]] +name = "profile_event_builder" +required-features = ["events"] + +[[test]] name = "food_availability_profile" required-features = ["events"] diff --git a/crates/nostr/README b/crates/nostr/README @@ -118,6 +118,13 @@ returned event only when the author and canonical event id match the request and the complete NIP-01 event verifies. It exposes no raw mutable builder, unsigned-event conversion, or unchecked deserialization path. +Strict kind-0 Profile publication uses +`RadrootsNostrProfileEventBuilder`, constructed only from +`RadrootsAuthoredProfile`. The sealed wrapper permits timestamp selection and +local signing or client publication, but no raw kind, content, or tag +mutation. A media-bearing Profile still requires runtime-owned proof of +successful BUD-02 upload before it reaches this authoring boundary. + ## Portable relay-client lifecycle With the `client` feature, callers can subscribe and publish to selected relay diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs @@ -16,6 +16,8 @@ use crate::events::deletion::RadrootsNostrNip09DeletionRequestEventBuilder; #[cfg(feature = "events")] use crate::events::food_availability::RadrootsNostrFoodAvailabilityEventBuilder; #[cfg(feature = "events")] +use crate::events::metadata::RadrootsNostrProfileEventBuilder; +#[cfg(feature = "events")] use crate::events::post::RadrootsNostrPostEventBuilder; #[cfg(feature = "events")] use crate::events::reply::RadrootsNostrNip10ReplyEventBuilder; @@ -264,6 +266,20 @@ impl RadrootsNostrClient { Ok(self.inner.send_event_builder(event).await?) } + /// Publishes a validated kind-0 Profile replacement snapshot. + /// + /// Media-bearing callers must prove successful BUD-02 upload first. + #[cfg(feature = "events")] + pub async fn send_profile_event_builder( + &self, + event: RadrootsNostrProfileEventBuilder, + ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + Ok(self + .inner + .send_event_builder(event.into_event_builder()) + .await?) + } + /// Publishes a validated root post through the sealed typed boundary. #[cfg(feature = "events")] pub async fn send_post_event_builder( @@ -367,6 +383,17 @@ pub async fn radroots_nostr_send_event( client.send_event_builder(event).await } +/// Publishes a validated kind-0 Profile replacement snapshot. +/// +/// Media-bearing callers must prove successful BUD-02 upload first. +#[cfg(feature = "events")] +pub async fn radroots_nostr_send_profile_event( + client: &RadrootsNostrClient, + event: RadrootsNostrProfileEventBuilder, +) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + client.send_profile_event_builder(event).await +} + /// Publishes a validated root post through the sealed typed boundary. #[cfg(feature = "events")] pub async fn radroots_nostr_send_post_event( diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs @@ -60,6 +60,12 @@ pub enum RadrootsNostrError { ), #[cfg(feature = "events")] + #[error("Profile encoding error: {0}")] + ProfileEncode( + #[from] radroots_event_codec::profile::authored::RadrootsAuthoredProfileEncodeError, + ), + + #[cfg(feature = "events")] #[error("Signed event error: {0}")] SignedEvent(#[from] radroots_event::draft::RadrootsSignedEventError), diff --git a/crates/nostr/src/events/metadata.rs b/crates/nostr/src/events/metadata.rs @@ -1,13 +1,63 @@ #[cfg(feature = "client")] use crate::client::RadrootsNostrClient; -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "events"))] use crate::error::RadrootsNostrError; +#[cfg(any(feature = "client", feature = "events"))] +use crate::types::RadrootsNostrEvent; +#[cfg(feature = "events")] +use crate::types::{RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys, RadrootsNostrTimestamp}; #[cfg(feature = "client")] -use crate::types::{ - RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, -}; +use crate::types::{RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey}; #[cfg(feature = "client")] use core::time::Duration; +#[cfg(feature = "events")] +use radroots_event::profile::RadrootsAuthoredProfile; +#[cfg(feature = "events")] +use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; + +/// A sealed builder for a validated kind-0 Profile replacement snapshot. +/// +/// The wrapper exposes no raw builder conversion or tag/content mutation. +/// Media-bearing profiles still require the owning runtime to prove successful +/// BUD-02 upload completion before signing or publication. +#[cfg(feature = "events")] +#[must_use = "Profile event builders must be signed or published"] +pub struct RadrootsNostrProfileEventBuilder { + inner: RadrootsNostrEventBuilderUnchecked, +} + +#[cfg(feature = "events")] +impl RadrootsNostrProfileEventBuilder { + /// Sets the event timestamp without changing the validated Profile shape. + pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { + self.inner = self.inner.custom_created_at(created_at); + self + } + + /// Signs the validated Profile directly with local keys. + pub fn sign_with_keys( + self, + keys: &RadrootsNostrKeys, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + Ok(self.inner.sign_with_keys(keys)?) + } + + #[cfg(feature = "client")] + pub(crate) fn into_event_builder(self) -> RadrootsNostrEventBuilderUnchecked { + self.inner + } +} + +/// Builds a sealed kind-0 event from the strict authored Profile contract. +#[cfg(feature = "events")] +pub fn radroots_nostr_build_profile_event( + profile: &RadrootsAuthoredProfile, +) -> Result<RadrootsNostrProfileEventBuilder, RadrootsNostrError> { + let parts = authored_profile_to_wire_parts(profile)?; + let inner = + crate::events::radroots_nostr_build_event_unchecked(parts.kind, parts.content, parts.tags)?; + Ok(RadrootsNostrProfileEventBuilder { inner }) +} #[cfg(feature = "client")] /// Fetches metadata through the legacy compatibility path. diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -95,6 +95,11 @@ pub mod prelude { }; #[cfg(feature = "events")] + pub use crate::events::metadata::{ + RadrootsNostrProfileEventBuilder, radroots_nostr_build_profile_event, + }; + + #[cfg(feature = "events")] pub use crate::events::post::{ RadrootsNostrPostEventBuilder, radroots_nostr_build_ask_event, radroots_nostr_build_photo_update_event, radroots_nostr_build_update_event, @@ -118,6 +123,9 @@ pub mod prelude { pub use crate::events::metadata::radroots_nostr_fetch_metadata_for_author; #[cfg(all(feature = "client", feature = "events"))] + pub use crate::client::radroots_nostr_send_profile_event; + + #[cfg(all(feature = "client", feature = "events"))] pub use crate::events::post::radroots_nostr_fetch_post_events; pub use crate::parse::{radroots_nostr_parse_pubkey, radroots_nostr_parse_pubkeys}; diff --git a/crates/nostr/tests/profile_event_builder.rs b/crates/nostr/tests/profile_event_builder.rs @@ -0,0 +1,36 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use radroots_event::profile::RadrootsAuthoredProfile; +use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, + radroots_nostr_build_profile_event, +}; + +#[test] +fn typed_profile_builder_preserves_the_strict_replacement_snapshot() { + let keys = RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_hex(test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX).unwrap(), + ); + let created_at = RadrootsNostrTimestamp::from_secs(1_784_347_200); + let profile = RadrootsAuthoredProfile::new("Alice") + .unwrap() + .with_display_name("Alice's Orchard") + .with_about("Tree fruit") + .with_bot(false); + + let event = radroots_nostr_build_profile_event(&profile) + .unwrap() + .custom_created_at(created_at) + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!(event.kind.as_u16(), 0); + assert_eq!(event.created_at, created_at); + assert!(event.tags.is_empty()); + assert_eq!( + event.content, + r#"{"name":"Alice","display_name":"Alice's Orchard","about":"Tree fruit","bot":false}"# + ); + assert!(event.verify().is_ok()); +}