commit 433f1b58b1d8dda29a8e020d891f21d6c73fb0ab
parent 87a0f082773f2dc9b4f4295d2ef5bef9d7f16a57
Author: triesap <triesap@radroots.dev>
Date: Tue, 6 Jan 2026 01:45:42 +0000
events: add profile publish rpc
- add events module with profile publish
- canonicalize profile metadata json
- support nip46-signed profile publishing
- reuse nip46 signing helper
Diffstat:
5 files changed, 193 insertions(+), 32 deletions(-)
diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs
@@ -0,0 +1,14 @@
+#![forbid(unsafe_code)]
+
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+
+use crate::api::jsonrpc::{MethodRegistry, RpcContext};
+
+pub mod profile;
+
+pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> {
+ let mut m = RpcModule::new(ctx);
+ profile::register(&mut m, ®istry)?;
+ Ok(m)
+}
diff --git a/src/api/jsonrpc/methods/events/profile.rs b/src/api/jsonrpc/methods/events/profile.rs
@@ -0,0 +1,134 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Deserialize;
+use serde_json::{Map, Value};
+
+use crate::api::jsonrpc::nostr::{publish_response, PublishResponse};
+use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use crate::nip46::client as nip46_client;
+use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
+use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type;
+use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event};
+
+#[derive(Debug, Deserialize)]
+struct PublishProfileParams {
+ profile: RadrootsProfile,
+ profile_type: RadrootsProfileType,
+ session_id: Option<String>,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("events.profile.publish");
+ m.register_async_method("events.profile.publish", |params, ctx, _| async move {
+ let PublishProfileParams {
+ profile,
+ profile_type,
+ session_id,
+ } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+
+ let parts = to_wire_parts_with_profile_type(&profile, Some(profile_type))
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let content = canonicalize_json_string(&parts.content)?;
+ let builder = radroots_nostr_build_event(parts.kind, content, parts.tags)
+ .map_err(|e| RpcError::Other(format!("failed to build profile event: {e}")))?;
+
+ let response = match session_id {
+ Some(session_id) => publish_with_session(ctx.as_ref().clone(), session_id, builder).await?,
+ None => publish_with_runtime(ctx.as_ref().clone(), builder).await?,
+ };
+
+ Ok::<PublishResponse, RpcError>(response)
+ })?;
+
+ Ok(())
+}
+
+async fn publish_with_runtime(
+ ctx: RpcContext,
+ builder: radroots_nostr::prelude::RadrootsNostrEventBuilder,
+) -> Result<PublishResponse, RpcError> {
+ let relays = ctx.state.client.relays().await;
+ if relays.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+
+ let output = radroots_nostr_send_event(&ctx.state.client, builder)
+ .await
+ .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?;
+
+ Ok(publish_response(output))
+}
+
+async fn publish_with_session(
+ ctx: RpcContext,
+ session_id: String,
+ builder: radroots_nostr::prelude::RadrootsNostrEventBuilder,
+) -> Result<PublishResponse, RpcError> {
+ let session = ctx
+ .state
+ .nip46_sessions
+ .get(&session_id)
+ .await
+ .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?;
+ if session.relays.is_empty() {
+ return Err(RpcError::NoRelays);
+ }
+ let user_pubkey = session.user_pubkey.clone().ok_or_else(|| {
+ RpcError::InvalidParams("missing user pubkey; call nip46.get_public_key".to_string())
+ })?;
+ let unsigned = builder.build(user_pubkey);
+ let signed = nip46_client::sign_event(&session, unsigned, "profile.publish").await?;
+ let output = session
+ .client
+ .send_event(&signed)
+ .await
+ .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?;
+
+ Ok(publish_response(output))
+}
+
+fn canonicalize_json_string(content: &str) -> Result<String, RpcError> {
+ let value: Value = serde_json::from_str(content)
+ .map_err(|e| RpcError::InvalidParams(format!("invalid metadata json: {e}")))?;
+ let canonical = canonicalize_value(value);
+ serde_json::to_string(&canonical)
+ .map_err(|e| RpcError::Other(format!("canonical json failed: {e}")))
+}
+
+fn canonicalize_value(value: Value) -> Value {
+ match value {
+ Value::Object(map) => canonicalize_object(map),
+ Value::Array(values) => {
+ let values = values
+ .into_iter()
+ .map(canonicalize_value)
+ .collect::<Vec<_>>();
+ Value::Array(values)
+ }
+ other => other,
+ }
+}
+
+fn canonicalize_object(map: Map<String, Value>) -> Value {
+ let mut entries = map.into_iter().collect::<Vec<_>>();
+ entries.sort_by(|a, b| a.0.cmp(&b.0));
+ let mut ordered = Map::new();
+ for (key, value) in entries {
+ ordered.insert(key, canonicalize_value(value));
+ }
+ Value::Object(ordered)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::canonicalize_json_string;
+
+ #[test]
+ fn canonicalize_json_string_orders_keys() {
+ let input = r#"{"b":1,"a":{"d":2,"c":3}}"#;
+ let canonical = canonicalize_json_string(input).expect("canonical");
+ assert_eq!(canonical, r#"{"a":{"c":3,"d":2},"b":1}"#);
+ }
+}
diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs
@@ -7,6 +7,7 @@ use super::{context::RpcContext, registry::MethodRegistry};
pub mod nip46;
pub mod relays;
+pub mod events;
pub fn register_all(
root: &mut RpcModule<RpcContext>,
@@ -14,6 +15,7 @@ pub fn register_all(
registry: MethodRegistry,
) -> Result<()> {
root.merge(relays::module(ctx.clone(), registry.clone())?)?;
- root.merge(nip46::module(ctx, registry)?)?;
+ root.merge(nip46::module(ctx.clone(), registry.clone())?)?;
+ root.merge(events::module(ctx, registry)?)?;
Ok(())
}
diff --git a/src/api/jsonrpc/methods/nip46/sign_event.rs b/src/api/jsonrpc/methods/nip46/sign_event.rs
@@ -7,7 +7,6 @@ use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError};
use crate::nip46::client;
use crate::nip46::session::Nip46Session;
use radroots_nostr::prelude::{RadrootsNostrKind, RadrootsNostrTag, RadrootsNostrTimestamp};
-use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult};
use nostr::UnsignedEvent;
#[derive(Debug, Deserialize)]
@@ -65,35 +64,7 @@ async fn sign_event(
input.content,
);
- let request = NostrConnectRequest::SignEvent(unsigned);
- let response = client::request(session, request, "sign_event").await?;
- let response = response
- .to_response(NostrConnectMethod::SignEvent)
- .map_err(|e| RpcError::Other(format!("nip46 sign_event failed: {e}")))?;
-
- if let Some(error) = response.error {
- return Err(RpcError::Other(format!("nip46 sign_event error: {error}")));
- }
-
- let event = match response.result {
- Some(ResponseResult::SignEvent(event)) => *event,
- Some(_) => {
- return Err(RpcError::Other(
- "nip46 sign_event unexpected response".to_string(),
- ))
- }
- None => {
- return Err(RpcError::Other(
- "nip46 sign_event missing response".to_string(),
- ))
- }
- };
-
- event
- .verify()
- .map_err(|e| RpcError::Other(format!("nip46 sign_event invalid event: {e}")))?;
-
- Ok(event)
+ client::sign_event(session, unsigned, "sign_event").await
}
fn parse_tags(tags: Vec<Vec<String>>) -> Result<Vec<RadrootsNostrTag>, RpcError> {
diff --git a/src/nip46/client.rs b/src/nip46/client.rs
@@ -10,8 +10,48 @@ use radroots_nostr::prelude::{
RadrootsNostrFilter,
RadrootsNostrKind,
};
-use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest};
+use nostr::nips::{
+ nip44,
+ nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult},
+};
use nostr::JsonUtil;
+use nostr::UnsignedEvent;
+
+pub async fn sign_event(
+ session: &Nip46Session,
+ unsigned: UnsignedEvent,
+ label: &str,
+) -> Result<nostr::Event, RpcError> {
+ let req = NostrConnectRequest::SignEvent(unsigned);
+ let response = request(session, req, label).await?;
+ let response = response
+ .to_response(NostrConnectMethod::SignEvent)
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+
+ if let Some(error) = response.error {
+ return Err(RpcError::Other(format!("nip46 {label} error: {error}")));
+ }
+
+ let event = match response.result {
+ Some(ResponseResult::SignEvent(event)) => *event,
+ Some(_) => {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} unexpected response"
+ )))
+ }
+ None => {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} missing response"
+ )))
+ }
+ };
+
+ event
+ .verify()
+ .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?;
+
+ Ok(event)
+}
pub async fn request(
session: &Nip46Session,