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:
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());
+}