lib

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

commit 3c2350da7924ed713aabfac941caa683f636eea7
parent e7eb1e4ad114630fe71de5596c9df2cc6d4e67e3
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 11:49:04 +0000

nostr-connect: add nip-46 protocol crate

Diffstat:
MCargo.lock | 11+++++++++++
MCargo.toml | 2++
Mcontract/coverage/policy.toml | 1+
Mcontract/release/publish-set.toml | 2++
Acrates/nostr-connect/Cargo.toml | 21+++++++++++++++++++++
Acrates/nostr-connect/src/error.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/src/lib.rs | 22++++++++++++++++++++++
Acrates/nostr-connect/src/message.rs | 549+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/src/method.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/src/permission.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/src/uri.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/tests/coverage.rs | 974+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-connect/tests/protocol.rs | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnix/common.nix | 1+
14 files changed, 2265 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2237,6 +2237,17 @@ dependencies = [ ] [[package]] +name = "radroots-nostr-connect" +version = "0.1.0-alpha.1" +dependencies = [ + "nostr", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", +] + +[[package]] name = "radroots-nostr-ndb" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/net-core", "crates/nostr", "crates/nostr-accounts", + "crates/nostr-connect", "crates/nostr-ndb", "crates/nostr-runtime", "crates/runtime", @@ -45,6 +46,7 @@ radroots-events-indexed = { path = "crates/events-indexed", version = "0.1.0-alp radroots-identity = { path = "crates/identity", version = "0.1.0-alpha.1", default-features = false } radroots-nostr = { path = "crates/nostr", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-accounts = { path = "crates/nostr-accounts", version = "0.1.0-alpha.1", default-features = false } +radroots-nostr-connect = { path = "crates/nostr-connect", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-ndb = { path = "crates/nostr-ndb", version = "0.1.0-alpha.1", default-features = false } radroots-runtime = { path = "crates/runtime", version = "0.1.0-alpha.1", default-features = false } radroots-log = { path = "crates/log", version = "0.1.0-alpha.1", default-features = false } diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml @@ -22,6 +22,7 @@ crates = [ "radroots-net", "radroots-nostr", "radroots-nostr-accounts", + "radroots-nostr-connect", "radroots-nostr-ndb", "radroots-nostr-runtime", "radroots-runtime", diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -14,6 +14,7 @@ crates = [ "radroots-events-codec-wasm", "radroots-events-indexed", "radroots-nostr", + "radroots-nostr-connect", "radroots-replica-db-schema", "radroots-sql-wasm-bridge", "radroots-nostr-runtime", @@ -45,6 +46,7 @@ crates = [ "radroots-events-codec-wasm", "radroots-events-indexed", "radroots-nostr", + "radroots-nostr-connect", "radroots-replica-db-schema", "radroots-sql-wasm-bridge", "radroots-nostr-runtime", diff --git a/crates/nostr-connect/Cargo.toml b/crates/nostr-connect/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "radroots-nostr-connect" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "nip-46 protocol and uri primitives for the radroots sdk" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-nostr-connect" +readme.workspace = true + +[dependencies] +nostr = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } diff --git a/crates/nostr-connect/src/error.rs b/crates/nostr-connect/src/error.rs @@ -0,0 +1,45 @@ +use thiserror::Error; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectError { + #[error("invalid NIP-46 method `{0}`")] + InvalidMethod(String), + #[error("invalid NIP-46 permission `{0}`")] + InvalidPermission(String), + #[error("invalid public key `{value}`: {reason}")] + InvalidPublicKey { value: String, reason: String }, + #[error("invalid relay url `{value}`: {reason}")] + InvalidRelayUrl { value: String, reason: String }, + #[error("invalid url `{value}`: {reason}")] + InvalidUrl { value: String, reason: String }, + #[error("invalid URI scheme `{0}`")] + InvalidUriScheme(String), + #[error("invalid NIP-46 uri")] + InvalidUri, + #[error("missing public key in URI authority")] + MissingPublicKey, + #[error("missing relay in URI")] + MissingRelay, + #[error("missing secret in nostrconnect uri")] + MissingSecret, + #[error("missing response result")] + MissingResult, + #[error("invalid parameter count for method `{method}`: expected {expected}, got {received}")] + InvalidParams { + method: String, + expected: &'static str, + received: usize, + }, + #[error("invalid request payload for method `{method}`: {reason}")] + InvalidRequestPayload { method: String, reason: String }, + #[error("invalid response payload for method `{method}`: {reason}")] + InvalidResponsePayload { method: String, reason: String }, + #[error("JSON error: {0}")] + Json(String), +} + +impl From<serde_json::Error> for RadrootsNostrConnectError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value.to_string()) + } +} diff --git a/crates/nostr-connect/src/lib.rs b/crates/nostr-connect/src/lib.rs @@ -0,0 +1,22 @@ +#![forbid(unsafe_code)] + +pub mod error; +pub mod message; +pub mod method; +pub mod permission; +pub mod uri; + +pub mod prelude { + pub use crate::error::RadrootsNostrConnectError; + pub use crate::message::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, + }; + pub use crate::method::RadrootsNostrConnectMethod; + pub use crate::permission::{RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions}; + pub use crate::uri::{ + RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata, + RadrootsNostrConnectClientUri, RadrootsNostrConnectUri, + }; +} diff --git a/crates/nostr-connect/src/message.rs b/crates/nostr-connect/src/message.rs @@ -0,0 +1,549 @@ +use crate::error::RadrootsNostrConnectError; +use crate::method::RadrootsNostrConnectMethod; +use crate::permission::RadrootsNostrConnectPermissions; +use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use std::str::FromStr; +use url::Url; + +pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectRequest { + Connect { + remote_signer_public_key: PublicKey, + secret: Option<String>, + requested_permissions: RadrootsNostrConnectPermissions, + }, + GetPublicKey, + SignEvent(UnsignedEvent), + Nip04Encrypt { + public_key: PublicKey, + plaintext: String, + }, + Nip04Decrypt { + public_key: PublicKey, + ciphertext: String, + }, + Nip44Encrypt { + public_key: PublicKey, + plaintext: String, + }, + Nip44Decrypt { + public_key: PublicKey, + ciphertext: String, + }, + Ping, + SwitchRelays, + Custom { + method: RadrootsNostrConnectMethod, + params: Vec<String>, + }, +} + +impl RadrootsNostrConnectRequest { + pub fn method(&self) -> RadrootsNostrConnectMethod { + match self { + Self::Connect { .. } => RadrootsNostrConnectMethod::Connect, + Self::GetPublicKey => RadrootsNostrConnectMethod::GetPublicKey, + Self::SignEvent(_) => RadrootsNostrConnectMethod::SignEvent, + Self::Nip04Encrypt { .. } => RadrootsNostrConnectMethod::Nip04Encrypt, + Self::Nip04Decrypt { .. } => RadrootsNostrConnectMethod::Nip04Decrypt, + Self::Nip44Encrypt { .. } => RadrootsNostrConnectMethod::Nip44Encrypt, + Self::Nip44Decrypt { .. } => RadrootsNostrConnectMethod::Nip44Decrypt, + Self::Ping => RadrootsNostrConnectMethod::Ping, + Self::SwitchRelays => RadrootsNostrConnectMethod::SwitchRelays, + Self::Custom { method, .. } => method.clone(), + } + } + + pub fn to_params(&self) -> Vec<String> { + match self { + Self::Connect { + remote_signer_public_key, + secret, + requested_permissions, + } => { + let mut params = vec![remote_signer_public_key.to_hex()]; + let normalized_secret = secret.as_ref().filter(|value| !value.is_empty()).cloned(); + if normalized_secret.is_some() || !requested_permissions.is_empty() { + params.push(normalized_secret.unwrap_or_default()); + } + if !requested_permissions.is_empty() { + params.push(requested_permissions.to_string()); + } + params + } + Self::GetPublicKey | Self::Ping | Self::SwitchRelays => Vec::new(), + Self::SignEvent(unsigned_event) => vec![unsigned_event.as_json()], + Self::Nip04Encrypt { + public_key, + plaintext, + } + | Self::Nip44Encrypt { + public_key, + plaintext, + } => vec![public_key.to_hex(), plaintext.clone()], + Self::Nip04Decrypt { + public_key, + ciphertext, + } + | Self::Nip44Decrypt { + public_key, + ciphertext, + } => vec![public_key.to_hex(), ciphertext.clone()], + Self::Custom { params, .. } => params.clone(), + } + } + + pub fn from_parts( + method: RadrootsNostrConnectMethod, + params: Vec<String>, + ) -> Result<Self, RadrootsNostrConnectError> { + match method { + RadrootsNostrConnectMethod::Connect => { + if params.is_empty() || params.len() > 3 { + return Err(RadrootsNostrConnectError::InvalidParams { + method: method.to_string(), + expected: "1 to 3 params", + received: params.len(), + }); + } + let remote_signer_public_key = parse_public_key(&params[0])?; + let secret = params + .get(1) + .cloned() + .and_then(|value| if value.is_empty() { None } else { Some(value) }); + let requested_permissions = match params.get(2) { + Some(value) => RadrootsNostrConnectPermissions::from_str(value)?, + None => RadrootsNostrConnectPermissions::default(), + }; + Ok(Self::Connect { + remote_signer_public_key, + secret, + requested_permissions, + }) + } + RadrootsNostrConnectMethod::GetPublicKey => { + expect_param_count(&method, &params, 0)?; + Ok(Self::GetPublicKey) + } + RadrootsNostrConnectMethod::SignEvent => { + expect_param_count(&method, &params, 1)?; + let unsigned_event = serde_json::from_str(&params[0]).map_err(|error| { + RadrootsNostrConnectError::InvalidRequestPayload { + method: method.to_string(), + reason: error.to_string(), + } + })?; + Ok(Self::SignEvent(unsigned_event)) + } + RadrootsNostrConnectMethod::Nip04Encrypt => { + expect_param_count(&method, &params, 2)?; + Ok(Self::Nip04Encrypt { + public_key: parse_public_key(&params[0])?, + plaintext: params[1].clone(), + }) + } + RadrootsNostrConnectMethod::Nip04Decrypt => { + expect_param_count(&method, &params, 2)?; + Ok(Self::Nip04Decrypt { + public_key: parse_public_key(&params[0])?, + ciphertext: params[1].clone(), + }) + } + RadrootsNostrConnectMethod::Nip44Encrypt => { + expect_param_count(&method, &params, 2)?; + Ok(Self::Nip44Encrypt { + public_key: parse_public_key(&params[0])?, + plaintext: params[1].clone(), + }) + } + RadrootsNostrConnectMethod::Nip44Decrypt => { + expect_param_count(&method, &params, 2)?; + Ok(Self::Nip44Decrypt { + public_key: parse_public_key(&params[0])?, + ciphertext: params[1].clone(), + }) + } + RadrootsNostrConnectMethod::Ping => { + expect_param_count(&method, &params, 0)?; + Ok(Self::Ping) + } + RadrootsNostrConnectMethod::SwitchRelays => { + expect_param_count(&method, &params, 0)?; + Ok(Self::SwitchRelays) + } + custom => Ok(Self::Custom { + method: custom, + params, + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsNostrConnectRequestMessage { + pub id: String, + pub request: RadrootsNostrConnectRequest, +} + +impl RadrootsNostrConnectRequestMessage { + pub fn new(id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self { + Self { + id: id.into(), + request, + } + } + + fn into_raw(self) -> RawRequestMessage { + RawRequestMessage { + id: self.id, + method: self.request.method(), + params: self.request.to_params(), + } + } + + fn from_raw(raw: RawRequestMessage) -> Result<Self, RadrootsNostrConnectError> { + Ok(Self { + id: raw.id, + request: RadrootsNostrConnectRequest::from_parts(raw.method, raw.params)?, + }) + } +} + +impl Serialize for RadrootsNostrConnectRequestMessage { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + self.clone().into_raw().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RadrootsNostrConnectRequestMessage { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let raw = RawRequestMessage::deserialize(deserializer)?; + Self::from_raw(raw).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrConnectResponseEnvelope { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectResponse { + ConnectAcknowledged, + ConnectSecretEcho(String), + UserPublicKey(PublicKey), + SignedEvent(Event), + Pong, + Nip04Encrypt(String), + Nip04Decrypt(String), + Nip44Encrypt(String), + Nip44Decrypt(String), + RelayList(Vec<RelayUrl>), + RelayListUnchanged, + AuthUrl(String), + Error { + result: Option<Value>, + error: String, + }, + Custom { + result: Option<Value>, + error: Option<String>, + }, +} + +impl RadrootsNostrConnectResponse { + pub fn into_envelope( + self, + id: impl Into<String>, + ) -> Result<RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectError> { + let id = id.into(); + let envelope = match self { + Self::ConnectAcknowledged => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String("ack".to_owned())), + error: None, + }, + Self::ConnectSecretEcho(secret) => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String(secret)), + error: None, + }, + Self::UserPublicKey(public_key) => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String(public_key.to_hex())), + error: None, + }, + Self::SignedEvent(event) => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String(event.as_json())), + error: None, + }, + Self::Pong => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String("pong".to_owned())), + error: None, + }, + Self::Nip04Encrypt(text) + | Self::Nip04Decrypt(text) + | Self::Nip44Encrypt(text) + | Self::Nip44Decrypt(text) => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String(text)), + error: None, + }, + Self::RelayList(relays) => { + let relays = relays + .into_iter() + .map(|relay| relay.to_string()) + .collect::<Vec<_>>(); + RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::Array( + relays.into_iter().map(Value::String).collect(), + )), + error: None, + } + } + Self::RelayListUnchanged => RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::Null), + error: None, + }, + Self::AuthUrl(url) => { + let normalized = validate_url(&url)?; + RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String("auth_url".to_owned())), + error: Some(normalized), + } + } + Self::Error { result, error } => RadrootsNostrConnectResponseEnvelope { + id, + result, + error: Some(error), + }, + Self::Custom { result, error } => { + RadrootsNostrConnectResponseEnvelope { id, result, error } + } + }; + Ok(envelope) + } + + pub fn from_envelope( + method: &RadrootsNostrConnectMethod, + envelope: RadrootsNostrConnectResponseEnvelope, + ) -> Result<Self, RadrootsNostrConnectError> { + if let (Some(Value::String(result)), Some(url)) = (&envelope.result, &envelope.error) { + if result == "auth_url" { + return Ok(Self::AuthUrl(validate_url(url)?)); + } + } + + if let Some(error) = envelope.error { + if let RadrootsNostrConnectMethod::Custom(_) = method { + return Ok(Self::Custom { + result: envelope.result, + error: Some(error), + }); + } + return Ok(Self::Error { + result: envelope.result, + error, + }); + } + + match method { + RadrootsNostrConnectMethod::Connect => { + let result = expect_string_result(method, envelope.result)?; + if result == "ack" { + Ok(Self::ConnectAcknowledged) + } else { + Ok(Self::ConnectSecretEcho(result)) + } + } + RadrootsNostrConnectMethod::GetPublicKey => { + let result = expect_string_result(method, envelope.result)?; + Ok(Self::UserPublicKey(parse_public_key(&result)?)) + } + RadrootsNostrConnectMethod::SignEvent => { + let event = parse_json_string_result::<Event>(method, envelope.result)?; + Ok(Self::SignedEvent(event)) + } + RadrootsNostrConnectMethod::Ping => { + let result = expect_string_result(method, envelope.result)?; + if result != "pong" { + return Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: format!("expected `pong`, got `{result}`"), + }); + } + Ok(Self::Pong) + } + RadrootsNostrConnectMethod::Nip04Encrypt => Ok(Self::Nip04Encrypt( + expect_string_result(method, envelope.result)?, + )), + RadrootsNostrConnectMethod::Nip04Decrypt => Ok(Self::Nip04Decrypt( + expect_string_result(method, envelope.result)?, + )), + RadrootsNostrConnectMethod::Nip44Encrypt => Ok(Self::Nip44Encrypt( + expect_string_result(method, envelope.result)?, + )), + RadrootsNostrConnectMethod::Nip44Decrypt => Ok(Self::Nip44Decrypt( + expect_string_result(method, envelope.result)?, + )), + RadrootsNostrConnectMethod::SwitchRelays => { + parse_switch_relays_response(envelope.result) + } + RadrootsNostrConnectMethod::Custom(_) => Ok(Self::Custom { + result: envelope.result, + error: None, + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RawRequestMessage { + id: String, + method: RadrootsNostrConnectMethod, + params: Vec<String>, +} + +fn expect_param_count( + method: &RadrootsNostrConnectMethod, + params: &[String], + expected: usize, +) -> Result<(), RadrootsNostrConnectError> { + if params.len() == expected { + return Ok(()); + } + + Err(RadrootsNostrConnectError::InvalidParams { + method: method.to_string(), + expected: if expected == 0 { + "no params" + } else if expected == 1 { + "exactly 1 param" + } else { + "exactly 2 params" + }, + received: params.len(), + }) +} + +fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> { + PublicKey::parse(value) + .or_else(|_| PublicKey::from_hex(value)) + .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey { + value: value.to_owned(), + reason: error.to_string(), + }) +} + +fn expect_string_result( + method: &RadrootsNostrConnectMethod, + result: Option<Value>, +) -> Result<String, RadrootsNostrConnectError> { + match result { + Some(Value::String(value)) => Ok(value), + Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: format!("expected string result, got {other}"), + }), + None => Err(RadrootsNostrConnectError::MissingResult), + } +} + +fn parse_json_string_result<T>( + method: &RadrootsNostrConnectMethod, + result: Option<Value>, +) -> Result<T, RadrootsNostrConnectError> +where + T: for<'de> Deserialize<'de>, +{ + match result { + Some(Value::String(value)) => serde_json::from_str(&value).map_err(|error| { + RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: error.to_string(), + } + }), + Some(other) => serde_json::from_value(other).map_err(|error| { + RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: error.to_string(), + } + }), + None => Err(RadrootsNostrConnectError::MissingResult), + } +} + +fn parse_switch_relays_response( + result: Option<Value>, +) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> { + let method = RadrootsNostrConnectMethod::SwitchRelays; + match result { + None | Some(Value::Null) => Ok(RadrootsNostrConnectResponse::RelayListUnchanged), + Some(Value::Array(values)) => { + let relays = parse_relay_values(values)?; + Ok(RadrootsNostrConnectResponse::RelayList(relays)) + } + Some(Value::String(value)) if value == "null" => { + Ok(RadrootsNostrConnectResponse::RelayListUnchanged) + } + Some(Value::String(value)) => { + let parsed = serde_json::from_str::<Value>(&value).map_err(|error| { + RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: error.to_string(), + } + })?; + parse_switch_relays_response(Some(parsed)) + } + Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: format!("expected relay list or null, got {other}"), + }), + } +} + +fn parse_relay_values(values: Vec<Value>) -> Result<Vec<RelayUrl>, RadrootsNostrConnectError> { + values + .into_iter() + .map(|value| match value { + Value::String(value) => RelayUrl::parse(&value).map_err(|error| { + RadrootsNostrConnectError::InvalidRelayUrl { + value, + reason: error.to_string(), + } + }), + other => Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: RadrootsNostrConnectMethod::SwitchRelays.to_string(), + reason: format!("expected relay string, got {other}"), + }), + }) + .collect() +} + +fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> { + Url::parse(value) + .map(|url| url.to_string()) + .map_err(|error| RadrootsNostrConnectError::InvalidUrl { + value: value.to_owned(), + reason: error.to_string(), + }) +} diff --git a/crates/nostr-connect/src/method.rs b/crates/nostr-connect/src/method.rs @@ -0,0 +1,80 @@ +use crate::error::RadrootsNostrConnectError; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsNostrConnectMethod { + Connect, + GetPublicKey, + SignEvent, + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, + Ping, + SwitchRelays, + Custom(String), +} + +impl RadrootsNostrConnectMethod { + pub fn as_str(&self) -> &str { + match self { + Self::Connect => "connect", + Self::GetPublicKey => "get_public_key", + Self::SignEvent => "sign_event", + Self::Nip04Encrypt => "nip04_encrypt", + Self::Nip04Decrypt => "nip04_decrypt", + Self::Nip44Encrypt => "nip44_encrypt", + Self::Nip44Decrypt => "nip44_decrypt", + Self::Ping => "ping", + Self::SwitchRelays => "switch_relays", + Self::Custom(value) => value.as_str(), + } + } +} + +impl fmt::Display for RadrootsNostrConnectMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsNostrConnectMethod { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "connect" => Ok(Self::Connect), + "get_public_key" => Ok(Self::GetPublicKey), + "sign_event" => Ok(Self::SignEvent), + "nip04_encrypt" => Ok(Self::Nip04Encrypt), + "nip04_decrypt" => Ok(Self::Nip04Decrypt), + "nip44_encrypt" => Ok(Self::Nip44Encrypt), + "nip44_decrypt" => Ok(Self::Nip44Decrypt), + "ping" => Ok(Self::Ping), + "switch_relays" => Ok(Self::SwitchRelays), + other if !other.trim().is_empty() => Ok(Self::Custom(other.to_owned())), + _ => Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned())), + } + } +} + +impl Serialize for RadrootsNostrConnectMethod { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(serde::de::Error::custom) + } +} diff --git a/crates/nostr-connect/src/permission.rs b/crates/nostr-connect/src/permission.rs @@ -0,0 +1,142 @@ +use crate::error::RadrootsNostrConnectError; +use crate::method::RadrootsNostrConnectMethod; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsNostrConnectPermission { + pub method: RadrootsNostrConnectMethod, + pub parameter: Option<String>, +} + +impl RadrootsNostrConnectPermission { + pub fn new(method: RadrootsNostrConnectMethod) -> Self { + Self { + method, + parameter: None, + } + } + + pub fn with_parameter( + method: RadrootsNostrConnectMethod, + parameter: impl Into<String>, + ) -> Self { + Self { + method, + parameter: Some(parameter.into()), + } + } +} + +impl fmt::Display for RadrootsNostrConnectPermission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.parameter.as_deref() { + Some(parameter) => write!(f, "{}:{parameter}", self.method), + None => write!(f, "{}", self.method), + } + } +} + +impl FromStr for RadrootsNostrConnectPermission { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RadrootsNostrConnectError::InvalidPermission( + value.to_owned(), + )); + } + + let (method, parameter) = match trimmed.split_once(':') { + Some((method, parameter)) if !parameter.is_empty() => (method, Some(parameter)), + Some(_) => { + return Err(RadrootsNostrConnectError::InvalidPermission( + value.to_owned(), + )); + } + None => (trimmed, None), + }; + + Ok(Self { + method: RadrootsNostrConnectMethod::from_str(method)?, + parameter: parameter.map(ToOwned::to_owned), + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsNostrConnectPermissions(Vec<RadrootsNostrConnectPermission>); + +impl RadrootsNostrConnectPermissions { + pub fn new() -> Self { + Self::default() + } + + pub fn as_slice(&self) -> &[RadrootsNostrConnectPermission] { + self.0.as_slice() + } + + pub fn into_vec(self) -> Vec<RadrootsNostrConnectPermission> { + self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl From<Vec<RadrootsNostrConnectPermission>> for RadrootsNostrConnectPermissions { + fn from(value: Vec<RadrootsNostrConnectPermission>) -> Self { + Self(value) + } +} + +impl fmt::Display for RadrootsNostrConnectPermissions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let rendered = self + .0 + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>() + .join(","); + f.write_str(&rendered) + } +} + +impl FromStr for RadrootsNostrConnectPermissions { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(Self::default()); + } + + let permissions = trimmed + .split(',') + .map(RadrootsNostrConnectPermission::from_str) + .collect::<Result<Vec<_>, _>>()?; + Ok(Self(permissions)) + } +} + +impl Serialize for RadrootsNostrConnectPermissions { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(serde::de::Error::custom) + } +} diff --git a/crates/nostr-connect/src/uri.rs b/crates/nostr-connect/src/uri.rs @@ -0,0 +1,204 @@ +use crate::error::RadrootsNostrConnectError; +use crate::permission::RadrootsNostrConnectPermissions; +use nostr::{PublicKey, RelayUrl}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; +use url::Url; + +pub const RADROOTS_NOSTR_CONNECT_URI_SCHEME: &str = "nostrconnect"; +pub const RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME: &str = "bunker"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrConnectBunkerUri { + pub remote_signer_public_key: PublicKey, + pub relays: Vec<RelayUrl>, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option<String>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrConnectClientMetadata { + #[serde( + default, + skip_serializing_if = "RadrootsNostrConnectPermissions::is_empty" + )] + pub requested_permissions: RadrootsNostrConnectPermissions, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrConnectClientUri { + pub client_public_key: PublicKey, + pub relays: Vec<RelayUrl>, + pub secret: String, + #[serde(default)] + pub metadata: RadrootsNostrConnectClientMetadata, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectUri { + Bunker(RadrootsNostrConnectBunkerUri), + Client(RadrootsNostrConnectClientUri), +} + +impl RadrootsNostrConnectUri { + pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> { + let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl { + value: value.to_owned(), + reason: error.to_string(), + })?; + let host = url + .host_str() + .ok_or(RadrootsNostrConnectError::MissingPublicKey)?; + + match url.scheme() { + RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME => { + let remote_signer_public_key = parse_public_key(host)?; + let mut relays = Vec::new(); + let mut secret = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "relay" => relays.push(parse_relay_url(value.as_ref())?), + "secret" => secret = Some(value.into_owned()), + _ => {} + } + } + + if relays.is_empty() { + return Err(RadrootsNostrConnectError::MissingRelay); + } + + Ok(Self::Bunker(RadrootsNostrConnectBunkerUri { + remote_signer_public_key, + relays, + secret, + })) + } + RADROOTS_NOSTR_CONNECT_URI_SCHEME => { + let client_public_key = parse_public_key(host)?; + let mut relays = Vec::new(); + let mut secret = None; + let mut metadata = RadrootsNostrConnectClientMetadata::default(); + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "relay" => relays.push(parse_relay_url(value.as_ref())?), + "secret" => secret = Some(value.into_owned()), + "perms" => { + metadata.requested_permissions = + RadrootsNostrConnectPermissions::from_str(value.as_ref())?; + } + "name" => metadata.name = Some(value.into_owned()), + "url" => metadata.url = Some(validate_url(value.as_ref())?), + "image" => metadata.image = Some(validate_url(value.as_ref())?), + _ => {} + } + } + + if relays.is_empty() { + return Err(RadrootsNostrConnectError::MissingRelay); + } + + let secret = secret.ok_or(RadrootsNostrConnectError::MissingSecret)?; + + Ok(Self::Client(RadrootsNostrConnectClientUri { + client_public_key, + relays, + secret, + metadata, + })) + } + scheme => Err(RadrootsNostrConnectError::InvalidUriScheme( + scheme.to_owned(), + )), + } + } +} + +impl FromStr for RadrootsNostrConnectUri { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl fmt::Display for RadrootsNostrConnectUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bunker(uri) => { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for relay in &uri.relays { + serializer.append_pair("relay", &relay.to_string()); + } + if let Some(secret) = &uri.secret { + serializer.append_pair("secret", secret); + } + let query = serializer.finish(); + write!( + f, + "{RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME}://{}?{query}", + uri.remote_signer_public_key + ) + } + Self::Client(uri) => { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for relay in &uri.relays { + serializer.append_pair("relay", &relay.to_string()); + } + serializer.append_pair("secret", &uri.secret); + if !uri.metadata.requested_permissions.is_empty() { + serializer + .append_pair("perms", &uri.metadata.requested_permissions.to_string()); + } + if let Some(name) = &uri.metadata.name { + serializer.append_pair("name", name); + } + if let Some(url) = &uri.metadata.url { + serializer.append_pair("url", url); + } + if let Some(image) = &uri.metadata.image { + serializer.append_pair("image", image); + } + let query = serializer.finish(); + write!( + f, + "{RADROOTS_NOSTR_CONNECT_URI_SCHEME}://{}?{query}", + uri.client_public_key + ) + } + } + } +} + +fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> { + PublicKey::parse(value) + .or_else(|_| PublicKey::from_hex(value)) + .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey { + value: value.to_owned(), + reason: error.to_string(), + }) +} + +fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsNostrConnectError> { + RelayUrl::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidRelayUrl { + value: value.to_owned(), + reason: error.to_string(), + }) +} + +fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> { + Url::parse(value) + .map(|url| url.to_string()) + .map_err(|error| RadrootsNostrConnectError::InvalidUrl { + value: value.to_owned(), + reason: error.to_string(), + }) +} diff --git a/crates/nostr-connect/tests/coverage.rs b/crates/nostr-connect/tests/coverage.rs @@ -0,0 +1,974 @@ +use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectError, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, +}; +use serde_json::{Value, json}; +use std::str::FromStr; + +fn test_public_key() -> PublicKey { + PublicKey::parse("83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5") + .expect("public key") +} + +fn test_keys() -> Keys { + let secret_key = + SecretKey::from_hex("6d5f4530cbf6a9e8f021eb409c8c5f2ee7ea123c76364b6f53c2d8a3507f7f5b") + .expect("secret key"); + Keys::new(secret_key) +} + +fn unsigned_event() -> UnsignedEvent { + serde_json::from_value(json!({ + "pubkey": test_public_key().to_hex(), + "created_at": 1714078911u64, + "kind": 1u16, + "tags": [], + "content": "hello" + })) + .expect("unsigned event") +} + +fn signed_event() -> Event { + EventBuilder::text_note("hello world") + .custom_created_at(Timestamp::from(1_714_078_911)) + .sign_with_keys(&test_keys()) + .expect("sign event") +} + +fn relay(value: &str) -> RelayUrl { + RelayUrl::parse(value).expect("relay") +} + +#[test] +fn error_method_and_permission_surfaces_cover_public_paths() { + let json_error = serde_json::from_str::<Value>("{").expect_err("invalid json"); + assert!(matches!( + RadrootsNostrConnectError::from(json_error), + RadrootsNostrConnectError::Json(message) if !message.is_empty() + )); + + let methods = [ + (RadrootsNostrConnectMethod::Connect, "connect"), + (RadrootsNostrConnectMethod::GetPublicKey, "get_public_key"), + (RadrootsNostrConnectMethod::SignEvent, "sign_event"), + (RadrootsNostrConnectMethod::Nip04Encrypt, "nip04_encrypt"), + (RadrootsNostrConnectMethod::Nip04Decrypt, "nip04_decrypt"), + (RadrootsNostrConnectMethod::Nip44Encrypt, "nip44_encrypt"), + (RadrootsNostrConnectMethod::Nip44Decrypt, "nip44_decrypt"), + (RadrootsNostrConnectMethod::Ping, "ping"), + (RadrootsNostrConnectMethod::SwitchRelays, "switch_relays"), + ]; + for (method, raw) in methods { + assert_eq!(method.as_str(), raw); + assert_eq!(method.to_string(), raw); + assert_eq!( + RadrootsNostrConnectMethod::from_str(raw).expect("parse method"), + method + ); + } + assert_eq!( + RadrootsNostrConnectMethod::from_str("publish_note").expect("custom method"), + RadrootsNostrConnectMethod::Custom("publish_note".to_owned()) + ); + assert!(matches!( + RadrootsNostrConnectMethod::from_str(" "), + Err(RadrootsNostrConnectError::InvalidMethod(value)) if value == " " + )); + assert_eq!( + serde_json::from_str::<RadrootsNostrConnectMethod>("\"do_work\"") + .expect("deserialize custom method"), + RadrootsNostrConnectMethod::Custom("do_work".to_owned()) + ); + assert!( + serde_json::from_str::<RadrootsNostrConnectMethod>("123") + .expect_err("non-string method") + .to_string() + .contains("invalid type") + ); + assert!( + serde_json::from_str::<RadrootsNostrConnectMethod>("\"\"") + .expect_err("blank method") + .to_string() + .contains("invalid NIP-46 method") + ); + + let simple = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping); + assert_eq!(simple.to_string(), "ping"); + let parameterized = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "1059", + ); + assert_eq!(parameterized.to_string(), "sign_event:1059"); + assert_eq!( + RadrootsNostrConnectPermission::from_str("sign_event:1059").expect("parse permission"), + parameterized + ); + assert!(matches!( + RadrootsNostrConnectPermission::from_str(" "), + Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == " " + )); + assert!(matches!( + RadrootsNostrConnectPermission::from_str("sign_event:"), + Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" + )); + assert!(matches!( + RadrootsNostrConnectPermission::from_str(" :kind"), + Err(RadrootsNostrConnectError::InvalidMethod(_)) + )); + + let empty = RadrootsNostrConnectPermissions::new(); + assert!(empty.is_empty()); + assert!(empty.as_slice().is_empty()); + assert!(empty.clone().into_vec().is_empty()); + assert_eq!( + RadrootsNostrConnectPermissions::from_str(" ").expect("empty permissions"), + empty + ); + + let permissions = RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), + RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"), + ]); + assert_eq!(permissions.to_string(), "nip44_encrypt,sign_event:13"); + assert_eq!( + serde_json::to_string(&permissions).expect("serialize permissions"), + "\"nip44_encrypt,sign_event:13\"" + ); + assert_eq!( + serde_json::from_str::<RadrootsNostrConnectPermissions>("\"nip44_encrypt,sign_event:13\"") + .expect("deserialize permissions"), + permissions + ); + assert!( + serde_json::from_str::<RadrootsNostrConnectPermissions>("123") + .expect_err("non-string permissions") + .to_string() + .contains("invalid type") + ); + assert!(matches!( + RadrootsNostrConnectPermissions::from_str("sign_event:,ping"), + Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" + )); +} + +#[test] +fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { + let bunker = RadrootsNostrConnectUri::parse( + "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&foo=bar", + ) + .expect("parse bunker"); + let bunker_rendered = bunker.to_string(); + assert!(bunker_rendered.contains("relay=wss%3A%2F%2Frelay.example.com")); + assert!(!bunker_rendered.contains("secret=")); + + let minimal_client: RadrootsNostrConnectUri = + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=shared" + .parse() + .expect("parse minimal client"); + let minimal_client_rendered = minimal_client.to_string(); + assert!(minimal_client_rendered.contains("secret=shared")); + assert!(!minimal_client_rendered.contains("perms=")); + assert!(!minimal_client_rendered.contains("name=")); + assert!(!minimal_client_rendered.contains("url=")); + assert!(!minimal_client_rendered.contains("image=")); + + let metadata_client = RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=shared&perms=ping&name=myc&url=https%3A%2F%2Fexample.com&image=https%3A%2F%2Fexample.com%2Flogo.png&ignored=value", + ) + .expect("parse metadata client"); + let metadata_rendered = metadata_client.to_string(); + assert!(metadata_rendered.contains("perms=ping")); + assert!(metadata_rendered.contains("name=myc")); + assert!(metadata_rendered.contains("url=https%3A%2F%2Fexample.com%2F")); + assert!(metadata_rendered.contains("image=https%3A%2F%2Fexample.com%2Flogo.png")); + + assert!(matches!( + RadrootsNostrConnectUri::parse("not a uri"), + Err(RadrootsNostrConnectError::InvalidUrl { .. }) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc" + ), + Err(RadrootsNostrConnectError::MissingPublicKey) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5" + ), + Err(RadrootsNostrConnectError::MissingRelay) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?secret=abc" + ), + Err(RadrootsNostrConnectError::MissingRelay) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com" + ), + Err(RadrootsNostrConnectError::MissingSecret) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse("https://example.com"), + Err(RadrootsNostrConnectError::InvalidUriScheme(value)) if value == "https" + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://bad-key?relay=wss%3A%2F%2Frelay.example.com&secret=abc" + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=http%3A%2F%2Frelay.example.com&secret=abc" + ), + Err(RadrootsNostrConnectError::InvalidRelayUrl { .. }) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&url=not-a-url" + ), + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + )); + assert!(matches!( + RadrootsNostrConnectUri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=http%3A%2F%2Frelay.example.com" + ), + Err(RadrootsNostrConnectError::InvalidRelayUrl { .. }) + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&perms=sign_event%3A" + ), + Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" + )); + assert!(matches!( + RadrootsNostrConnectUri::parse( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&image=not-a-url" + ), + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + )); +} + +#[test] +fn request_surface_covers_variant_methods_serialization_and_validation() { + let ping_permission = + RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )]); + + let requests = vec![ + ( + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: test_public_key(), + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + RadrootsNostrConnectMethod::Connect, + vec![test_public_key().to_hex()], + ), + ( + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: test_public_key(), + secret: None, + requested_permissions: ping_permission.clone(), + }, + RadrootsNostrConnectMethod::Connect, + vec![test_public_key().to_hex(), String::new(), "ping".to_owned()], + ), + ( + RadrootsNostrConnectRequest::GetPublicKey, + RadrootsNostrConnectMethod::GetPublicKey, + Vec::new(), + ), + ( + RadrootsNostrConnectRequest::SignEvent(unsigned_event()), + RadrootsNostrConnectMethod::SignEvent, + vec![serde_json::to_string(&unsigned_event()).expect("serialize unsigned event")], + ), + ( + RadrootsNostrConnectRequest::Nip04Encrypt { + public_key: test_public_key(), + plaintext: "hello".to_owned(), + }, + RadrootsNostrConnectMethod::Nip04Encrypt, + vec![test_public_key().to_hex(), "hello".to_owned()], + ), + ( + RadrootsNostrConnectRequest::Nip04Decrypt { + public_key: test_public_key(), + ciphertext: "cipher".to_owned(), + }, + RadrootsNostrConnectMethod::Nip04Decrypt, + vec![test_public_key().to_hex(), "cipher".to_owned()], + ), + ( + RadrootsNostrConnectRequest::Nip44Encrypt { + public_key: test_public_key(), + plaintext: "hello".to_owned(), + }, + RadrootsNostrConnectMethod::Nip44Encrypt, + vec![test_public_key().to_hex(), "hello".to_owned()], + ), + ( + RadrootsNostrConnectRequest::Nip44Decrypt { + public_key: test_public_key(), + ciphertext: "cipher".to_owned(), + }, + RadrootsNostrConnectMethod::Nip44Decrypt, + vec![test_public_key().to_hex(), "cipher".to_owned()], + ), + ( + RadrootsNostrConnectRequest::Ping, + RadrootsNostrConnectMethod::Ping, + Vec::new(), + ), + ( + RadrootsNostrConnectRequest::SwitchRelays, + RadrootsNostrConnectMethod::SwitchRelays, + Vec::new(), + ), + ( + RadrootsNostrConnectRequest::Custom { + method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + params: vec!["one".to_owned(), "two".to_owned()], + }, + RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + vec!["one".to_owned(), "two".to_owned()], + ), + ]; + for (request, method, params) in requests { + assert_eq!(request.method(), method); + assert_eq!(request.to_params(), params); + } + + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec![test_public_key().to_hex()], + ) + .expect("connect without secret or perms"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: test_public_key(), + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec![test_public_key().to_hex(), String::new(), "ping".to_owned()], + ) + .expect("connect with empty secret"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: test_public_key(), + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), + ]), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::GetPublicKey, + Vec::new(), + ) + .expect("get_public_key from parts"), + RadrootsNostrConnectRequest::GetPublicKey + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip04Encrypt, + vec![test_public_key().to_hex(), "hello".to_owned()], + ) + .expect("nip04 encrypt from parts"), + RadrootsNostrConnectRequest::Nip04Encrypt { + public_key: test_public_key(), + plaintext: "hello".to_owned(), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip04Decrypt, + vec![test_public_key().to_hex(), "cipher".to_owned()], + ) + .expect("nip04 decrypt from parts"), + RadrootsNostrConnectRequest::Nip04Decrypt { + public_key: test_public_key(), + ciphertext: "cipher".to_owned(), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip44Encrypt, + vec![test_public_key().to_hex(), "hello".to_owned()], + ) + .expect("nip44 encrypt from parts"), + RadrootsNostrConnectRequest::Nip44Encrypt { + public_key: test_public_key(), + plaintext: "hello".to_owned(), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip44Decrypt, + vec![test_public_key().to_hex(), "cipher".to_owned()], + ) + .expect("nip44 decrypt from parts"), + RadrootsNostrConnectRequest::Nip44Decrypt { + public_key: test_public_key(), + ciphertext: "cipher".to_owned(), + } + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Ping, Vec::new()) + .expect("ping from parts"), + RadrootsNostrConnectRequest::Ping + ); + assert_eq!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::SwitchRelays, + Vec::new(), + ) + .expect("switch relays from parts"), + RadrootsNostrConnectRequest::SwitchRelays + ); + + for (method, params, expected_error) in [ + ( + RadrootsNostrConnectMethod::GetPublicKey, + vec!["oops".to_owned()], + "no params", + ), + ( + RadrootsNostrConnectMethod::SignEvent, + Vec::new(), + "exactly 1 param", + ), + ( + RadrootsNostrConnectMethod::Nip04Encrypt, + vec!["only-one".to_owned()], + "exactly 2 params", + ), + ( + RadrootsNostrConnectMethod::Nip04Decrypt, + vec!["only-one".to_owned()], + "exactly 2 params", + ), + ( + RadrootsNostrConnectMethod::Nip44Encrypt, + vec!["only-one".to_owned()], + "exactly 2 params", + ), + ( + RadrootsNostrConnectMethod::Nip44Decrypt, + vec!["only-one".to_owned()], + "exactly 2 params", + ), + ( + RadrootsNostrConnectMethod::Ping, + vec!["oops".to_owned()], + "no params", + ), + ( + RadrootsNostrConnectMethod::SwitchRelays, + vec!["oops".to_owned()], + "no params", + ), + ] { + assert!(matches!( + RadrootsNostrConnectRequest::from_parts(method, params), + Err(RadrootsNostrConnectError::InvalidParams { expected, .. }) if expected == expected_error + )); + } + assert!(matches!( + RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Connect, Vec::new()), + Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. }) + if expected == "1 to 3 params" && received == 0 + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec!["bad-key".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec![test_public_key().to_hex(), "secret".to_owned(), "sign_event:".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec![ + test_public_key().to_hex(), + "secret".to_owned(), + "ping".to_owned(), + "extra".to_owned(), + ], + ), + Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. }) + if expected == "1 to 3 params" && received == 4 + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::SignEvent, + vec!["not-json".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidRequestPayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip04Encrypt, + vec!["bad-key".to_owned(), "hello".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip04Decrypt, + vec!["bad-key".to_owned(), "cipher".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip44Encrypt, + vec!["bad-key".to_owned(), "hello".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Nip44Decrypt, + vec!["bad-key".to_owned(), "cipher".to_owned()], + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + + let custom_message = RadrootsNostrConnectRequestMessage::new( + "req-custom", + RadrootsNostrConnectRequest::Custom { + method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + params: vec!["a".to_owned()], + }, + ); + let encoded = serde_json::to_string(&custom_message).expect("serialize custom request"); + let decoded: RadrootsNostrConnectRequestMessage = + serde_json::from_str(&encoded).expect("deserialize custom request"); + assert_eq!(decoded, custom_message); + assert!( + serde_json::from_str::<RadrootsNostrConnectRequestMessage>("{") + .expect_err("invalid request message json") + .to_string() + .contains("EOF") + ); + assert!( + serde_json::from_str::<RadrootsNostrConnectRequestMessage>( + "{\"id\":\"req\",\"method\":\"get_public_key\",\"params\":[\"oops\"]}", + ) + .expect_err("invalid request params") + .to_string() + .contains("invalid parameter count") + ); +} + +#[test] +fn response_surface_covers_success_and_error_paths() { + let event = signed_event(); + let cases = vec![ + ( + RadrootsNostrConnectResponse::ConnectAcknowledged, + RadrootsNostrConnectMethod::Connect, + RadrootsNostrConnectResponse::ConnectAcknowledged, + ), + ( + RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()), + RadrootsNostrConnectMethod::Connect, + RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()), + ), + ( + RadrootsNostrConnectResponse::UserPublicKey(test_public_key()), + RadrootsNostrConnectMethod::GetPublicKey, + RadrootsNostrConnectResponse::UserPublicKey(test_public_key()), + ), + ( + RadrootsNostrConnectResponse::SignedEvent(event.clone()), + RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponse::SignedEvent(event.clone()), + ), + ( + RadrootsNostrConnectResponse::Pong, + RadrootsNostrConnectMethod::Ping, + RadrootsNostrConnectResponse::Pong, + ), + ( + RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()), + RadrootsNostrConnectMethod::Nip04Encrypt, + RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()), + ), + ( + RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()), + RadrootsNostrConnectMethod::Nip04Decrypt, + RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()), + ), + ( + RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()), + RadrootsNostrConnectMethod::Nip44Encrypt, + RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()), + ), + ( + RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()), + RadrootsNostrConnectMethod::Nip44Decrypt, + RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()), + ), + ( + RadrootsNostrConnectResponse::RelayList(vec![ + relay("wss://relay1.example.com"), + relay("wss://relay2.example.com"), + ]), + RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponse::RelayList(vec![ + relay("wss://relay1.example.com"), + relay("wss://relay2.example.com"), + ]), + ), + ( + RadrootsNostrConnectResponse::RelayListUnchanged, + RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponse::RelayListUnchanged, + ), + ]; + for (response, method, expected) in cases { + let envelope = response.into_envelope("req").expect("serialize response"); + let parsed = + RadrootsNostrConnectResponse::from_envelope(&method, envelope).expect("parse response"); + assert_eq!(parsed, expected); + } + + let error_envelope = RadrootsNostrConnectResponse::Error { + result: Some(json!("partial")), + error: "denied".to_owned(), + } + .into_envelope("req-error") + .expect("serialize error response"); + assert_eq!(error_envelope.error.as_deref(), Some("denied")); + + let custom_envelope = RadrootsNostrConnectResponse::Custom { + result: Some(json!({"ok": true})), + error: Some("warning".to_owned()), + } + .into_envelope("req-custom") + .expect("serialize custom response"); + assert_eq!(custom_envelope.error.as_deref(), Some("warning")); + + let auth_envelope = + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()) + .into_envelope("req-auth") + .expect("serialize auth_url"); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + auth_envelope, + ) + .expect("parse auth_url"), + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()) + ); + + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + RadrootsNostrConnectResponseEnvelope { + id: "req-custom".to_owned(), + result: Some(json!("ok")), + error: None, + }, + ) + .expect("parse custom response without error"), + RadrootsNostrConnectResponse::Custom { + result: Some(json!("ok")), + error: None, + } + ); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + RadrootsNostrConnectResponseEnvelope { + id: "req-custom".to_owned(), + result: Some(json!({"ok": true})), + error: Some("warning".to_owned()), + }, + ) + .expect("parse custom response"), + RadrootsNostrConnectResponse::Custom { + result: Some(json!({"ok": true})), + error: Some("warning".to_owned()), + } + ); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Ping, + RadrootsNostrConnectResponseEnvelope { + id: "req-error".to_owned(), + result: Some(json!("partial")), + error: Some("denied".to_owned()), + }, + ) + .expect("parse error response"), + RadrootsNostrConnectResponse::Error { + result: Some(json!("partial")), + error: "denied".to_owned(), + } + ); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-event".to_owned(), + result: Some(serde_json::to_value(&event).expect("event value")), + error: None, + }, + ) + .expect("parse object event"), + RadrootsNostrConnectResponse::SignedEvent(event) + ); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!("null")), + error: None, + }, + ) + .expect("parse string null"), + RadrootsNostrConnectResponse::RelayListUnchanged + ); + assert_eq!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!("[\"wss://relay1.example.com\"]")), + error: None, + }, + ) + .expect("parse stringified relay list"), + RadrootsNostrConnectResponse::RelayList(vec![relay("wss://relay1.example.com")]) + ); + + assert!(matches!( + RadrootsNostrConnectResponse::AuthUrl("not-a-url".to_owned()).into_envelope("req"), + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-auth".to_owned(), + result: Some(json!("auth_url")), + error: Some("not-a-url".to_owned()), + }, + ), + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::GetPublicKey, + RadrootsNostrConnectResponseEnvelope { + id: "req-key".to_owned(), + result: Some(json!("bad-key")), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Connect, + RadrootsNostrConnectResponseEnvelope { + id: "req-connect".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::GetPublicKey, + RadrootsNostrConnectResponseEnvelope { + id: "req-key".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Ping, + RadrootsNostrConnectResponseEnvelope { + id: "req-ping".to_owned(), + result: Some(json!("nope")), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Ping, + RadrootsNostrConnectResponseEnvelope { + id: "req-ping".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Nip04Encrypt, + RadrootsNostrConnectResponseEnvelope { + id: "req-nip04".to_owned(), + result: Some(json!(5)), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Nip04Encrypt, + RadrootsNostrConnectResponseEnvelope { + id: "req-nip04".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-event".to_owned(), + result: Some(json!("not-json")), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-event".to_owned(), + result: Some(json!(5)), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-event".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Nip04Decrypt, + RadrootsNostrConnectResponseEnvelope { + id: "req-nip04d".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Nip44Encrypt, + RadrootsNostrConnectResponseEnvelope { + id: "req-nip44e".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Nip44Decrypt, + RadrootsNostrConnectResponseEnvelope { + id: "req-nip44d".to_owned(), + result: None, + error: None, + }, + ), + Err(RadrootsNostrConnectError::MissingResult) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!("[invalid")), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!([1])), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!(["http://relay.example.com"])), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidRelayUrl { .. }) + )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!(5)), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) + )); +} diff --git a/crates/nostr-connect/tests/protocol.rs b/crates/nostr-connect/tests/protocol.rs @@ -0,0 +1,211 @@ +use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, +}; +use serde_json::{Value, json}; + +fn test_public_key() -> PublicKey { + PublicKey::parse("83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5") + .expect("public key") +} + +fn test_keys() -> Keys { + let secret_key = + SecretKey::from_hex("6d5f4530cbf6a9e8f021eb409c8c5f2ee7ea123c76364b6f53c2d8a3507f7f5b") + .expect("secret key"); + Keys::new(secret_key) +} + +#[test] +fn parses_client_uri_with_current_spec_query_fields() { + let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&relay=wss%3A%2F%2Frelay2.example.com&secret=0s8j2djs&perms=nip44_encrypt%2Csign_event%3A1059&name=My+Client&url=https%3A%2F%2Fexample.com&image=https%3A%2F%2Fexample.com%2Flogo.png"; + let parsed = RadrootsNostrConnectUri::parse(uri).expect("parse client uri"); + + match parsed { + RadrootsNostrConnectUri::Client(client) => { + assert_eq!(client.client_public_key, test_public_key()); + assert_eq!(client.relays.len(), 2); + assert_eq!(client.secret, "0s8j2djs"); + assert_eq!(client.metadata.name.as_deref(), Some("My Client")); + assert_eq!( + client.metadata.requested_permissions, + RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt,), + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "1059", + ), + ]) + ); + assert_eq!(client.metadata.url.as_deref(), Some("https://example.com/")); + assert_eq!( + client.metadata.image.as_deref(), + Some("https://example.com/logo.png") + ); + } + other => panic!("expected client uri, got {other:?}"), + } +} + +#[test] +fn parses_bunker_uri_and_roundtrips() { + let source = "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abcd"; + let parsed = RadrootsNostrConnectUri::parse(source).expect("parse bunker uri"); + let rendered = parsed.to_string(); + let reparsed = RadrootsNostrConnectUri::parse(&rendered).expect("reparse bunker uri"); + assert_eq!(parsed, reparsed); +} + +#[test] +fn rejects_client_uri_without_required_secret() { + let source = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com"; + assert!(RadrootsNostrConnectUri::parse(source).is_err()); +} + +#[test] +fn requested_permissions_roundtrip_as_csv() { + let permissions = RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), + RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"), + ]); + + let rendered = permissions.to_string(); + assert_eq!(rendered, "nip44_encrypt,sign_event:13"); + let reparsed: RadrootsNostrConnectPermissions = rendered.parse().expect("parse permissions"); + assert_eq!(permissions, reparsed); +} + +#[test] +fn connect_request_roundtrips_requested_permissions() { + let request = RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: test_public_key(), + secret: Some("abcd".to_owned()), + requested_permissions: RadrootsNostrConnectPermissions::from(vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "1059", + ), + ]), + }; + let message = RadrootsNostrConnectRequestMessage::new("req-1", request); + let encoded = serde_json::to_value(&message).expect("serialize request"); + assert_eq!( + encoded, + json!({ + "id": "req-1", + "method": "connect", + "params": [ + "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5", + "abcd", + "nip44_encrypt,sign_event:1059" + ] + }) + ); + + let decoded: RadrootsNostrConnectRequestMessage = + serde_json::from_value(encoded).expect("deserialize request"); + assert_eq!(decoded, message); +} + +#[test] +fn sign_event_request_roundtrips_unsigned_event_payload() { + let unsigned_event: UnsignedEvent = serde_json::from_value(json!({ + "pubkey": test_public_key().to_hex(), + "created_at": 1714078911u64, + "kind": 1u16, + "tags": [], + "content": "Hello, I'm signing remotely" + })) + .expect("unsigned event"); + + let message = RadrootsNostrConnectRequestMessage::new( + "req-sign", + RadrootsNostrConnectRequest::SignEvent(unsigned_event.clone()), + ); + let encoded = serde_json::to_value(&message).expect("serialize sign request"); + assert_eq!(encoded["method"], "sign_event"); + + let decoded: RadrootsNostrConnectRequestMessage = + serde_json::from_value(encoded).expect("deserialize sign request"); + assert_eq!(decoded, message); + assert_eq!( + decoded.request, + RadrootsNostrConnectRequest::SignEvent(unsigned_event) + ); +} + +#[test] +fn switch_relays_response_accepts_array_or_null() { + let relays_response = RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(json!([ + "wss://relay1.example.com", + "wss://relay2.example.com" + ])), + error: None, + }; + let parsed = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + relays_response, + ) + .expect("parse relay list"); + assert_eq!( + parsed, + RadrootsNostrConnectResponse::RelayList(vec![ + RelayUrl::parse("wss://relay1.example.com").expect("relay 1"), + RelayUrl::parse("wss://relay2.example.com").expect("relay 2"), + ]) + ); + + let unchanged = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectResponseEnvelope { + id: "req-switch".to_owned(), + result: Some(Value::Null), + error: None, + }, + ) + .expect("parse null relay result"); + assert_eq!(unchanged, RadrootsNostrConnectResponse::RelayListUnchanged); +} + +#[test] +fn auth_url_response_parses_from_result_and_error_fields() { + let response = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + RadrootsNostrConnectResponseEnvelope { + id: "req-auth".to_owned(), + result: Some(json!("auth_url")), + error: Some("https://auth.example.com/challenge".to_owned()), + }, + ) + .expect("parse auth challenge"); + + assert_eq!( + response, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()) + ); +} + +#[test] +fn sign_event_response_roundtrips_signed_event_json_string() { + let keys = test_keys(); + let event = EventBuilder::text_note("hello world") + .custom_created_at(Timestamp::from(1_714_078_911)) + .sign_with_keys(&keys) + .expect("sign event"); + + let envelope = RadrootsNostrConnectResponse::SignedEvent(event.clone()) + .into_envelope("req-sign") + .expect("serialize response"); + let parsed = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::SignEvent, + envelope, + ) + .expect("parse signed event response"); + + assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event)); +} diff --git a/nix/common.nix b/nix/common.nix @@ -102,6 +102,7 @@ let "radroots-replica-db-schema" "radroots-events-codec" "radroots-events-codec-wasm" + "radroots-nostr-connect" ]; sdkContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") sdkContractCrates); craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable;