commit 853ef12beecd5d700336e2998b6382ba91df1838
parent 19521ad129c89393401eba44f27d415d2b7308e1
Author: triesap <tyson@radroots.org>
Date: Fri, 31 Jul 2026 17:22:57 +0000
nostr-connect: normalize URI, method, and permission types
- adopt concise module-owned NIP-46 type names and canonical root exports
- bound and canonicalize custom methods, permissions, metadata, relays, and secrets
- use identity-owned public keys while keeping relay representation package-private
- add protocol regressions and repair the isolated signer feature declaration
Diffstat:
14 files changed, 1131 insertions(+), 670 deletions(-)
diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs
@@ -4,7 +4,7 @@ use crate::message::{
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope,
};
-use crate::method::RadrootsNostrConnectMethod;
+use crate::method::Method;
use nostr::nips::nip44::{self, Version};
use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag};
use std::future::Future;
@@ -42,7 +42,7 @@ impl RadrootsNostrConnectClientRequest {
}
}
- pub fn method(&self) -> RadrootsNostrConnectMethod {
+ pub fn method(&self) -> Method {
self.request.method()
}
@@ -98,7 +98,7 @@ pub fn parse_response_event(
client_keys: &Keys,
target: &RadrootsNostrConnectClientTarget,
request_id: &str,
- method: &RadrootsNostrConnectMethod,
+ method: &Method,
event: &Event,
) -> Result<RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectError> {
if event.kind != Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs
@@ -9,6 +9,11 @@ pub mod permission;
pub mod server;
pub mod uri;
+pub use error::RadrootsNostrConnectError as Error;
+pub use method::Method;
+pub use permission::Permission;
+pub use uri::{BunkerUri, ClientUri};
+
// Transitional compatibility surface for consumers migrated in Step 141.
// Publication remains disabled, and Step 143 removes this module.
#[doc(hidden)]
@@ -27,12 +32,25 @@ pub mod prelude {
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope,
};
- pub use crate::method::RadrootsNostrConnectMethod;
- pub use crate::permission::{RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions};
+ pub use crate::method::Method;
+ pub use crate::method::Method as RadrootsNostrConnectMethod;
+ pub use crate::permission::{Permission, Permissions};
+ pub use crate::permission::{
+ Permission as RadrootsNostrConnectPermission,
+ Permissions as RadrootsNostrConnectPermissions,
+ };
+ pub use crate::uri::{
+ BUNKER_URI_SCHEME, BunkerUri, CLIENT_METADATA_JSON_MAX_BYTES, CLIENT_NAME_MAX_BYTES,
+ CLIENT_URL_MAX_BYTES, ClientMetadata, ClientUri, URI_SCHEME, Uri,
+ };
pub use crate::uri::{
- RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES,
- RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES, RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES,
- RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata,
- RadrootsNostrConnectClientUri, RadrootsNostrConnectUri,
+ BUNKER_URI_SCHEME as RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME,
+ BunkerUri as RadrootsNostrConnectBunkerUri,
+ CLIENT_METADATA_JSON_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES,
+ CLIENT_NAME_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES,
+ CLIENT_URL_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES,
+ ClientMetadata as RadrootsNostrConnectClientMetadata,
+ ClientUri as RadrootsNostrConnectClientUri,
+ URI_SCHEME as RADROOTS_NOSTR_CONNECT_URI_SCHEME, Uri as RadrootsNostrConnectUri,
};
}
diff --git a/crates/nostr_connect/src/message.rs b/crates/nostr_connect/src/message.rs
@@ -1,7 +1,7 @@
use crate::error::RadrootsNostrConnectError;
-use crate::method::RadrootsNostrConnectMethod;
-use crate::permission::RadrootsNostrConnectPermissions;
-use crate::uri::RadrootsNostrConnectClientMetadata;
+use crate::method::Method;
+use crate::permission::Permissions;
+use crate::uri::ClientMetadata;
use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Value, json};
@@ -14,7 +14,7 @@ pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133;
pub struct RadrootsNostrConnectRemoteSessionCapability {
pub user_public_key: PublicKey,
pub relays: Vec<RelayUrl>,
- pub permissions: RadrootsNostrConnectPermissions,
+ pub permissions: Permissions,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -22,8 +22,8 @@ pub enum RadrootsNostrConnectRequest {
Connect {
remote_signer_public_key: PublicKey,
secret: Option<String>,
- requested_permissions: RadrootsNostrConnectPermissions,
- client_metadata: Option<RadrootsNostrConnectClientMetadata>,
+ requested_permissions: Permissions,
+ client_metadata: Option<ClientMetadata>,
},
GetPublicKey,
GetSessionCapability,
@@ -48,25 +48,25 @@ pub enum RadrootsNostrConnectRequest {
SwitchRelays,
Logout,
Custom {
- method: RadrootsNostrConnectMethod,
+ method: Method,
params: Vec<String>,
},
}
impl RadrootsNostrConnectRequest {
- pub fn method(&self) -> RadrootsNostrConnectMethod {
+ pub fn method(&self) -> Method {
match self {
- Self::Connect { .. } => RadrootsNostrConnectMethod::Connect,
- Self::GetPublicKey => RadrootsNostrConnectMethod::GetPublicKey,
- Self::GetSessionCapability => RadrootsNostrConnectMethod::GetSessionCapability,
- 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::Logout => RadrootsNostrConnectMethod::Logout,
+ Self::Connect { .. } => Method::Connect,
+ Self::GetPublicKey => Method::GetPublicKey,
+ Self::GetSessionCapability => Method::GetSessionCapability,
+ Self::SignEvent(_) => Method::SignEvent,
+ Self::Nip04Encrypt { .. } => Method::Nip04Encrypt,
+ Self::Nip04Decrypt { .. } => Method::Nip04Decrypt,
+ Self::Nip44Encrypt { .. } => Method::Nip44Encrypt,
+ Self::Nip44Decrypt { .. } => Method::Nip44Decrypt,
+ Self::Ping => Method::Ping,
+ Self::SwitchRelays => Method::SwitchRelays,
+ Self::Logout => Method::Logout,
Self::Custom { method, .. } => method.clone(),
}
}
@@ -123,11 +123,11 @@ impl RadrootsNostrConnectRequest {
}
pub fn from_parts(
- method: RadrootsNostrConnectMethod,
+ method: Method,
params: Vec<String>,
) -> Result<Self, RadrootsNostrConnectError> {
match method {
- RadrootsNostrConnectMethod::Connect => {
+ Method::Connect => {
if params.is_empty() || params.len() > 4 {
return Err(RadrootsNostrConnectError::InvalidParams {
method: method.to_string(),
@@ -138,12 +138,12 @@ impl RadrootsNostrConnectRequest {
let remote_signer_public_key = parse_public_key(¶ms[0])?;
let secret = params.get(1).cloned().filter(|value| !value.is_empty());
let requested_permissions = match params.get(2) {
- Some(value) => RadrootsNostrConnectPermissions::from_str(value)?,
- None => RadrootsNostrConnectPermissions::default(),
+ Some(value) => Permissions::from_str(value)?,
+ None => Permissions::default(),
};
let client_metadata = params
.get(3)
- .map(|value| RadrootsNostrConnectClientMetadata::from_connect_param(value))
+ .map(|value| ClientMetadata::from_connect_param(value))
.transpose()?;
Ok(Self::Connect {
remote_signer_public_key,
@@ -152,15 +152,15 @@ impl RadrootsNostrConnectRequest {
client_metadata,
})
}
- RadrootsNostrConnectMethod::GetPublicKey => {
+ Method::GetPublicKey => {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::GetPublicKey)
}
- RadrootsNostrConnectMethod::GetSessionCapability => {
+ Method::GetSessionCapability => {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::GetSessionCapability)
}
- RadrootsNostrConnectMethod::SignEvent => {
+ Method::SignEvent => {
expect_param_count(&method, ¶ms, 1)?;
let unsigned_event = serde_json::from_str(¶ms[0]).map_err(|error| {
RadrootsNostrConnectError::InvalidRequestPayload {
@@ -170,43 +170,43 @@ impl RadrootsNostrConnectRequest {
})?;
Ok(Self::SignEvent(unsigned_event))
}
- RadrootsNostrConnectMethod::Nip04Encrypt => {
+ Method::Nip04Encrypt => {
expect_param_count(&method, ¶ms, 2)?;
Ok(Self::Nip04Encrypt {
public_key: parse_public_key(¶ms[0])?,
plaintext: params[1].clone(),
})
}
- RadrootsNostrConnectMethod::Nip04Decrypt => {
+ Method::Nip04Decrypt => {
expect_param_count(&method, ¶ms, 2)?;
Ok(Self::Nip04Decrypt {
public_key: parse_public_key(¶ms[0])?,
ciphertext: params[1].clone(),
})
}
- RadrootsNostrConnectMethod::Nip44Encrypt => {
+ Method::Nip44Encrypt => {
expect_param_count(&method, ¶ms, 2)?;
Ok(Self::Nip44Encrypt {
public_key: parse_public_key(¶ms[0])?,
plaintext: params[1].clone(),
})
}
- RadrootsNostrConnectMethod::Nip44Decrypt => {
+ Method::Nip44Decrypt => {
expect_param_count(&method, ¶ms, 2)?;
Ok(Self::Nip44Decrypt {
public_key: parse_public_key(¶ms[0])?,
ciphertext: params[1].clone(),
})
}
- RadrootsNostrConnectMethod::Ping => {
+ Method::Ping => {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::Ping)
}
- RadrootsNostrConnectMethod::SwitchRelays => {
+ Method::SwitchRelays => {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::SwitchRelays)
}
- RadrootsNostrConnectMethod::Logout => {
+ Method::Logout => {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::Logout)
}
@@ -439,7 +439,7 @@ impl RadrootsNostrConnectResponse {
}
pub fn from_envelope(
- method: &RadrootsNostrConnectMethod,
+ method: &Method,
envelope: RadrootsNostrConnectResponseEnvelope,
) -> Result<Self, RadrootsNostrConnectError> {
if let (Some(Value::String(result)), Some(url)) = (&envelope.result, &envelope.error)
@@ -449,16 +449,13 @@ impl RadrootsNostrConnectResponse {
}
if let Some(error) = envelope.error {
- if matches!(
- method,
- RadrootsNostrConnectMethod::GetPublicKey
- | RadrootsNostrConnectMethod::GetSessionCapability
- ) && envelope.result.is_none()
+ if matches!(method, Method::GetPublicKey | Method::GetSessionCapability)
+ && envelope.result.is_none()
&& error == RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR
{
return Ok(Self::PendingConnection);
}
- if let RadrootsNostrConnectMethod::Custom(_) = method {
+ if let Method::Custom(_) = method {
return Ok(Self::Custom {
result: envelope.result,
error: Some(error),
@@ -471,7 +468,7 @@ impl RadrootsNostrConnectResponse {
}
match method {
- RadrootsNostrConnectMethod::Connect => {
+ Method::Connect => {
let result = expect_string_result(method, envelope.result)?;
if result == "ack" {
Ok(Self::ConnectAcknowledged)
@@ -479,19 +476,19 @@ impl RadrootsNostrConnectResponse {
Ok(Self::ConnectSecretEcho(result))
}
}
- RadrootsNostrConnectMethod::GetPublicKey => {
+ Method::GetPublicKey => {
let result = expect_string_result(method, envelope.result)?;
Ok(Self::UserPublicKey(parse_public_key(&result)?))
}
- RadrootsNostrConnectMethod::GetSessionCapability => {
+ Method::GetSessionCapability => {
let capability = parse_json_string_result(method, envelope.result)?;
Ok(Self::RemoteSessionCapability(capability))
}
- RadrootsNostrConnectMethod::SignEvent => {
+ Method::SignEvent => {
let event = parse_json_string_result::<Event>(method, envelope.result)?;
Ok(Self::SignedEvent(event))
}
- RadrootsNostrConnectMethod::Ping => {
+ Method::Ping => {
let result = expect_string_result(method, envelope.result)?;
if result != "pong" {
return Err(RadrootsNostrConnectError::InvalidResponsePayload {
@@ -501,22 +498,24 @@ impl RadrootsNostrConnectResponse {
}
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::Logout => {
+ Method::Nip04Encrypt => Ok(Self::Nip04Encrypt(expect_string_result(
+ method,
+ envelope.result,
+ )?)),
+ Method::Nip04Decrypt => Ok(Self::Nip04Decrypt(expect_string_result(
+ method,
+ envelope.result,
+ )?)),
+ Method::Nip44Encrypt => Ok(Self::Nip44Encrypt(expect_string_result(
+ method,
+ envelope.result,
+ )?)),
+ Method::Nip44Decrypt => Ok(Self::Nip44Decrypt(expect_string_result(
+ method,
+ envelope.result,
+ )?)),
+ Method::SwitchRelays => parse_switch_relays_response(envelope.result),
+ Method::Logout => {
let result = expect_string_result(method, envelope.result)?;
if result != "ack" {
return Err(RadrootsNostrConnectError::InvalidResponsePayload {
@@ -526,7 +525,7 @@ impl RadrootsNostrConnectResponse {
}
Ok(Self::LogoutAcknowledged)
}
- RadrootsNostrConnectMethod::Custom(_) => Ok(Self::Custom {
+ Method::Custom(_) => Ok(Self::Custom {
result: envelope.result,
error: None,
}),
@@ -547,12 +546,12 @@ fn remote_session_capability_value(
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct RawRequestMessage {
id: String,
- method: RadrootsNostrConnectMethod,
+ method: Method,
params: Vec<String>,
}
fn expect_param_count(
- method: &RadrootsNostrConnectMethod,
+ method: &Method,
params: &[String],
expected: usize,
) -> Result<(), RadrootsNostrConnectError> {
@@ -583,7 +582,7 @@ fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError>
}
fn expect_string_result(
- method: &RadrootsNostrConnectMethod,
+ method: &Method,
result: Option<Value>,
) -> Result<String, RadrootsNostrConnectError> {
match result {
@@ -597,7 +596,7 @@ fn expect_string_result(
}
fn parse_json_string_result<T>(
- method: &RadrootsNostrConnectMethod,
+ method: &Method,
result: Option<Value>,
) -> Result<T, RadrootsNostrConnectError>
where
@@ -623,7 +622,7 @@ where
fn parse_switch_relays_response(
result: Option<Value>,
) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> {
- let method = RadrootsNostrConnectMethod::SwitchRelays;
+ let method = Method::SwitchRelays;
match result {
None | Some(Value::Null) => Ok(RadrootsNostrConnectResponse::RelayListUnchanged),
Some(Value::Array(values)) => {
@@ -660,7 +659,7 @@ fn parse_relay_values(values: Vec<Value>) -> Result<Vec<RelayUrl>, RadrootsNostr
}
}),
other => Err(RadrootsNostrConnectError::InvalidResponsePayload {
- method: RadrootsNostrConnectMethod::SwitchRelays.to_string(),
+ method: Method::SwitchRelays.to_string(),
reason: format!("expected relay string, got {other}"),
}),
})
diff --git a/crates/nostr_connect/src/method.rs b/crates/nostr_connect/src/method.rs
@@ -3,8 +3,27 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
+/// Maximum UTF-8 byte length of a NIP-46 method identifier.
+pub const METHOD_MAX_BYTES: usize = 64;
+
+/// A validated extension method identifier.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub enum RadrootsNostrConnectMethod {
+pub struct CustomMethod(String);
+
+impl CustomMethod {
+ fn new(value: String) -> Result<Self, RadrootsNostrConnectError> {
+ validate_custom_method(&value)?;
+ Ok(Self(value))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum Method {
Connect,
GetPublicKey,
GetSessionCapability,
@@ -16,10 +35,17 @@ pub enum RadrootsNostrConnectMethod {
Ping,
SwitchRelays,
Logout,
- Custom(String),
+ Custom(CustomMethod),
}
-impl RadrootsNostrConnectMethod {
+impl Method {
+ /// Creates a bounded custom method identifier.
+ pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> {
+ CustomMethod::new(value.into()).map(Self::Custom)
+ }
+
+ /// Returns the canonical wire identifier.
+ #[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Connect => "connect",
@@ -38,13 +64,13 @@ impl RadrootsNostrConnectMethod {
}
}
-impl fmt::Display for RadrootsNostrConnectMethod {
+impl fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
-impl FromStr for RadrootsNostrConnectMethod {
+impl FromStr for Method {
type Err = RadrootsNostrConnectError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
@@ -60,13 +86,12 @@ impl FromStr for RadrootsNostrConnectMethod {
"ping" => Ok(Self::Ping),
"switch_relays" => Ok(Self::SwitchRelays),
"logout" => Ok(Self::Logout),
- other if !other.trim().is_empty() => Ok(Self::Custom(other.to_owned())),
- _ => Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned())),
+ other => Self::custom(other),
}
}
}
-impl Serialize for RadrootsNostrConnectMethod {
+impl Serialize for Method {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
@@ -75,7 +100,7 @@ impl Serialize for RadrootsNostrConnectMethod {
}
}
-impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod {
+impl<'de> Deserialize<'de> for Method {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
@@ -84,3 +109,15 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod {
Self::from_str(&value).map_err(serde::de::Error::custom)
}
}
+
+fn validate_custom_method(value: &str) -> Result<(), RadrootsNostrConnectError> {
+ if value.is_empty()
+ || value.len() > METHOD_MAX_BYTES
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
+ {
+ return Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned()));
+ }
+ Ok(())
+}
diff --git a/crates/nostr_connect/src/permission.rs b/crates/nostr_connect/src/permission.rs
@@ -1,44 +1,75 @@
use crate::error::RadrootsNostrConnectError;
-use crate::method::RadrootsNostrConnectMethod;
+use crate::method::Method;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
+/// Maximum UTF-8 byte length of one permission parameter.
+pub const PERMISSION_PARAMETER_MAX_BYTES: usize = 64;
+/// Maximum number of permissions accepted from one wire value.
+pub const PERMISSION_COUNT_MAX: usize = 64;
+/// Maximum UTF-8 byte length of the comma-separated permission wire value.
+pub const PERMISSIONS_MAX_BYTES: usize = 4_096;
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsNostrConnectPermission {
- pub method: RadrootsNostrConnectMethod,
+pub struct Permission {
+ #[doc(hidden)]
+ pub method: Method,
+ #[doc(hidden)]
pub parameter: Option<String>,
}
-impl RadrootsNostrConnectPermission {
- pub fn new(method: RadrootsNostrConnectMethod) -> Self {
+impl Permission {
+ #[must_use]
+ pub fn new(method: Method) -> Self {
Self {
method,
parameter: None,
}
}
- pub fn with_parameter(
- method: RadrootsNostrConnectMethod,
+ /// Creates a permission with a bounded canonical parameter.
+ pub fn try_with_parameter(
+ method: Method,
parameter: impl Into<String>,
- ) -> Self {
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ let parameter = parameter.into();
+ validate_parameter(¶meter)?;
+ Ok(Self {
+ method,
+ parameter: Some(parameter),
+ })
+ }
+
+ /// Compatibility constructor retained until the Step 141 consumer cutover.
+ #[doc(hidden)]
+ #[must_use]
+ pub fn with_parameter(method: Method, parameter: impl Into<String>) -> Self {
Self {
method,
parameter: Some(parameter.into()),
}
}
- pub fn matches_request(
- &self,
- method: &RadrootsNostrConnectMethod,
- parameter: Option<&str>,
- ) -> bool {
+ /// Returns the permission method.
+ #[must_use]
+ pub fn method(&self) -> &Method {
+ &self.method
+ }
+
+ /// Returns the optional method-specific parameter.
+ #[must_use]
+ pub fn parameter(&self) -> Option<&str> {
+ self.parameter.as_deref()
+ }
+
+ pub fn matches_request(&self, method: &Method, parameter: Option<&str>) -> bool {
if self.method != *method {
return false;
}
match (&self.method, self.parameter.as_deref(), parameter) {
- (RadrootsNostrConnectMethod::SignEvent, None, _) => true,
- (RadrootsNostrConnectMethod::SignEvent, Some(configured), Some(requested)) => {
+ (Method::SignEvent, None, _) => true,
+ (Method::SignEvent, Some(configured), Some(requested)) => {
match (
sign_event_kind_parameter(configured),
sign_event_kind_parameter(requested),
@@ -55,14 +86,11 @@ impl RadrootsNostrConnectPermission {
pub fn matches_sign_event_kind(&self, event_kind: u32) -> bool {
let event_kind = event_kind.to_string();
- self.matches_request(
- &RadrootsNostrConnectMethod::SignEvent,
- Some(event_kind.as_str()),
- )
+ self.matches_request(&Method::SignEvent, Some(event_kind.as_str()))
}
}
-impl fmt::Display for RadrootsNostrConnectPermission {
+impl fmt::Display for Permission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.parameter.as_deref() {
Some(parameter) => write!(f, "{}:{parameter}", self.method),
@@ -71,7 +99,7 @@ impl fmt::Display for RadrootsNostrConnectPermission {
}
}
-impl FromStr for RadrootsNostrConnectPermission {
+impl FromStr for Permission {
type Err = RadrootsNostrConnectError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
@@ -92,26 +120,27 @@ impl FromStr for RadrootsNostrConnectPermission {
None => (trimmed, None),
};
- Ok(Self {
- method: RadrootsNostrConnectMethod::from_str(method)?,
- parameter: parameter.map(ToOwned::to_owned),
- })
+ let method = Method::from_str(method)?;
+ match parameter {
+ Some(parameter) => Self::try_with_parameter(method, parameter),
+ None => Ok(Self::new(method)),
+ }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct RadrootsNostrConnectPermissions(Vec<RadrootsNostrConnectPermission>);
+pub struct Permissions(Vec<Permission>);
-impl RadrootsNostrConnectPermissions {
+impl Permissions {
pub fn new() -> Self {
Self::default()
}
- pub fn as_slice(&self) -> &[RadrootsNostrConnectPermission] {
+ pub fn as_slice(&self) -> &[Permission] {
self.0.as_slice()
}
- pub fn into_vec(self) -> Vec<RadrootsNostrConnectPermission> {
+ pub fn into_vec(self) -> Vec<Permission> {
self.0
}
@@ -119,11 +148,14 @@ impl RadrootsNostrConnectPermissions {
self.0.is_empty()
}
- pub fn allows_request(
- &self,
- method: &RadrootsNostrConnectMethod,
- parameter: Option<&str>,
- ) -> bool {
+ /// Validates and canonicalizes a permission collection.
+ pub fn try_from_vec(value: Vec<Permission>) -> Result<Self, RadrootsNostrConnectError> {
+ let value = canonicalize(value);
+ validate_permissions(&value)?;
+ Ok(Self(value))
+ }
+
+ pub fn allows_request(&self, method: &Method, parameter: Option<&str>) -> bool {
self.0
.iter()
.any(|permission| permission.matches_request(method, parameter))
@@ -144,13 +176,13 @@ fn sign_event_kind_parameter(value: &str) -> Option<u32> {
value.parse().ok()
}
-impl From<Vec<RadrootsNostrConnectPermission>> for RadrootsNostrConnectPermissions {
- fn from(value: Vec<RadrootsNostrConnectPermission>) -> Self {
- Self(value)
+impl From<Vec<Permission>> for Permissions {
+ fn from(value: Vec<Permission>) -> Self {
+ Self(canonicalize(value))
}
}
-impl fmt::Display for RadrootsNostrConnectPermissions {
+impl fmt::Display for Permissions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rendered = self
.0
@@ -162,7 +194,7 @@ impl fmt::Display for RadrootsNostrConnectPermissions {
}
}
-impl FromStr for RadrootsNostrConnectPermissions {
+impl FromStr for Permissions {
type Err = RadrootsNostrConnectError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
@@ -170,25 +202,31 @@ impl FromStr for RadrootsNostrConnectPermissions {
if trimmed.is_empty() {
return Ok(Self::default());
}
+ if trimmed.len() > PERMISSIONS_MAX_BYTES {
+ return Err(invalid_permissions(
+ "serialized permission set exceeds its byte limit",
+ ));
+ }
let permissions = trimmed
.split(',')
- .map(RadrootsNostrConnectPermission::from_str)
+ .map(Permission::from_str)
.collect::<Result<Vec<_>, _>>()?;
- Ok(Self(permissions))
+ Self::try_from_vec(permissions)
}
}
-impl Serialize for RadrootsNostrConnectPermissions {
+impl Serialize for Permissions {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
+ validate_permissions(&self.0).map_err(serde::ser::Error::custom)?;
serializer.serialize_str(&self.to_string())
}
}
-impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions {
+impl<'de> Deserialize<'de> for Permissions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
@@ -197,3 +235,50 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions {
Self::from_str(&value).map_err(serde::de::Error::custom)
}
}
+
+fn canonicalize(mut permissions: Vec<Permission>) -> Vec<Permission> {
+ permissions.sort_by_key(ToString::to_string);
+ permissions.dedup();
+ permissions
+}
+
+fn validate_permissions(permissions: &[Permission]) -> Result<(), RadrootsNostrConnectError> {
+ if permissions.len() > PERMISSION_COUNT_MAX {
+ return Err(invalid_permissions("permission count exceeds its limit"));
+ }
+ for permission in permissions {
+ Method::from_str(permission.method.as_str())?;
+ if let Some(parameter) = permission.parameter.as_deref() {
+ validate_parameter(parameter)?;
+ }
+ }
+ let rendered = permissions
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(",");
+ if rendered.len() > PERMISSIONS_MAX_BYTES {
+ return Err(invalid_permissions(
+ "serialized permission set exceeds its byte limit",
+ ));
+ }
+ Ok(())
+}
+
+fn validate_parameter(value: &str) -> Result<(), RadrootsNostrConnectError> {
+ if value.is_empty()
+ || value.len() > PERMISSION_PARAMETER_MAX_BYTES
+ || value.trim() != value
+ || value.contains(',')
+ || value.chars().any(char::is_control)
+ {
+ return Err(RadrootsNostrConnectError::InvalidPermission(
+ value.to_owned(),
+ ));
+ }
+ Ok(())
+}
+
+fn invalid_permissions(reason: &str) -> RadrootsNostrConnectError {
+ RadrootsNostrConnectError::InvalidPermission(reason.to_owned())
+}
diff --git a/crates/nostr_connect/src/uri.rs b/crates/nostr_connect/src/uri.rs
@@ -1,54 +1,136 @@
use crate::error::RadrootsNostrConnectError;
-use crate::permission::RadrootsNostrConnectPermissions;
-use nostr::{PublicKey, RelayUrl};
+use crate::permission::Permissions;
+use radroots_identity::PublicKey;
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";
-pub const RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES: usize = 128;
-pub const RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES: usize = 2_048;
-pub const RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES: usize = 4_352;
-
-#[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)]
-pub struct RadrootsNostrConnectClientMetadata {
- #[serde(
- default,
- skip_serializing_if = "RadrootsNostrConnectPermissions::is_empty"
- )]
- pub requested_permissions: RadrootsNostrConnectPermissions,
- #[serde(skip_serializing_if = "Option::is_none")]
+pub const URI_SCHEME: &str = "nostrconnect";
+pub const BUNKER_URI_SCHEME: &str = "bunker";
+pub const CLIENT_NAME_MAX_BYTES: usize = 128;
+pub const CLIENT_URL_MAX_BYTES: usize = 2_048;
+pub const CLIENT_METADATA_JSON_MAX_BYTES: usize = 4_352;
+pub const URI_MAX_BYTES: usize = 16_384;
+pub const RELAY_COUNT_MAX: usize = 32;
+pub const SECRET_MAX_BYTES: usize = 1_024;
+
+/// A validated WebSocket relay URL owned by the NIP-46 protocol package.
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub struct RelayUrl(nostr::RelayUrl);
+
+impl RelayUrl {
+ /// Parses a relay URL accepted by the underlying Nostr wire protocol.
+ pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> {
+ nostr::RelayUrl::parse(value).map(Self).map_err(|error| {
+ RadrootsNostrConnectError::InvalidRelayUrl {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ }
+ })
+ }
+}
+
+impl fmt::Debug for RelayUrl {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.debug_tuple("RelayUrl").field(&self.0).finish()
+ }
+}
+
+impl fmt::Display for RelayUrl {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(formatter)
+ }
+}
+
+impl FromStr for RelayUrl {
+ type Err = RadrootsNostrConnectError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl Serialize for RelayUrl {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for RelayUrl {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+pub struct BunkerUri {
+ remote_signer_public_key: PublicKey,
+ relays: Vec<RelayUrl>,
+ secret: Option<String>,
+}
+
+impl BunkerUri {
+ #[must_use]
+ pub const fn remote_signer_public_key(&self) -> PublicKey {
+ self.remote_signer_public_key
+ }
+
+ #[must_use]
+ pub fn relays(&self) -> &[RelayUrl] {
+ &self.relays
+ }
+
+ #[must_use]
+ pub fn secret(&self) -> Option<&str> {
+ self.secret.as_deref()
+ }
+}
+
+impl fmt::Debug for BunkerUri {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("BunkerUri")
+ .field("remote_signer_public_key", &self.remote_signer_public_key)
+ .field("relays", &self.relays)
+ .field("secret", &self.secret.as_ref().map(|_| "[redacted]"))
+ .finish()
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ClientMetadata {
+ #[doc(hidden)]
+ pub requested_permissions: Permissions,
+ #[doc(hidden)]
pub name: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
+ #[doc(hidden)]
pub url: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
+ #[doc(hidden)]
pub image: Option<String>,
}
-#[derive(Debug, Deserialize)]
-struct RadrootsNostrConnectClientMetadataSerde {
- #[serde(default)]
- requested_permissions: RadrootsNostrConnectPermissions,
- #[serde(default)]
+#[derive(Debug, Serialize, Deserialize)]
+struct ClientMetadataSerde {
+ #[serde(default, skip_serializing_if = "Permissions::is_empty")]
+ requested_permissions: Permissions,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
- #[serde(default)]
+ #[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
- #[serde(default)]
+ #[serde(default, skip_serializing_if = "Option::is_none")]
image: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-struct RadrootsNostrConnectClientMetadataWire {
+struct ClientMetadataWire {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -57,7 +139,59 @@ struct RadrootsNostrConnectClientMetadataWire {
image: Option<String>,
}
-impl RadrootsNostrConnectClientMetadata {
+impl ClientMetadata {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ #[must_use]
+ pub fn requested_permissions(&self) -> &Permissions {
+ &self.requested_permissions
+ }
+
+ #[must_use]
+ pub fn name(&self) -> Option<&str> {
+ self.name.as_deref()
+ }
+
+ #[must_use]
+ pub fn url(&self) -> Option<&str> {
+ self.url.as_deref()
+ }
+
+ #[must_use]
+ pub fn image(&self) -> Option<&str> {
+ self.image.as_deref()
+ }
+
+ #[must_use]
+ pub fn with_requested_permissions(mut self, permissions: Permissions) -> Self {
+ self.requested_permissions = permissions;
+ self
+ }
+
+ pub fn with_name(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ self.name = Some(normalize_client_name(&value.into())?);
+ Ok(self)
+ }
+
+ pub fn with_url(mut self, value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> {
+ self.url = Some(normalize_client_url("url", &value.into())?);
+ Ok(self)
+ }
+
+ pub fn with_image(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ self.image = Some(normalize_client_url("image", &value.into())?);
+ Ok(self)
+ }
+
pub fn normalized(self) -> Result<Self, RadrootsNostrConnectError> {
Ok(Self {
requested_permissions: self.requested_permissions,
@@ -78,7 +212,7 @@ impl RadrootsNostrConnectClientMetadata {
pub fn to_connect_param(&self) -> Result<String, RadrootsNostrConnectError> {
let normalized = self.clone().normalized()?;
- let wire = RadrootsNostrConnectClientMetadataWire {
+ let wire = ClientMetadataWire {
name: normalized.name,
url: normalized.url,
image: normalized.image,
@@ -90,15 +224,14 @@ impl RadrootsNostrConnectClientMetadata {
pub fn from_connect_param(value: &str) -> Result<Self, RadrootsNostrConnectError> {
validate_metadata_size(value)?;
- let wire: RadrootsNostrConnectClientMetadataWire =
- serde_json::from_str(value).map_err(|error| {
- RadrootsNostrConnectError::InvalidClientMetadata {
- field: "payload",
- reason: error.to_string(),
- }
- })?;
+ let wire: ClientMetadataWire = serde_json::from_str(value).map_err(|error| {
+ RadrootsNostrConnectError::InvalidClientMetadata {
+ field: "payload",
+ reason: error.to_string(),
+ }
+ })?;
Self {
- requested_permissions: RadrootsNostrConnectPermissions::default(),
+ requested_permissions: Permissions::default(),
name: wire.name,
url: wire.url,
image: wire.image,
@@ -111,12 +244,31 @@ impl RadrootsNostrConnectClientMetadata {
}
}
-impl<'de> Deserialize<'de> for RadrootsNostrConnectClientMetadata {
+impl Serialize for ClientMetadata {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let normalized = self
+ .clone()
+ .normalized()
+ .map_err(serde::ser::Error::custom)?;
+ ClientMetadataSerde {
+ requested_permissions: normalized.requested_permissions,
+ name: normalized.name,
+ url: normalized.url,
+ image: normalized.image,
+ }
+ .serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for ClientMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
- let metadata = RadrootsNostrConnectClientMetadataSerde::deserialize(deserializer)?;
+ let metadata = ClientMetadataSerde::deserialize(deserializer)?;
Self {
requested_permissions: metadata.requested_permissions,
name: metadata.name,
@@ -128,25 +280,61 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectClientMetadata {
}
}
-#[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(Clone, PartialEq, Eq)]
+pub struct ClientUri {
+ client_public_key: PublicKey,
+ relays: Vec<RelayUrl>,
+ secret: String,
+ metadata: ClientMetadata,
+}
+
+impl ClientUri {
+ #[must_use]
+ pub const fn client_public_key(&self) -> PublicKey {
+ self.client_public_key
+ }
+
+ #[must_use]
+ pub fn relays(&self) -> &[RelayUrl] {
+ &self.relays
+ }
+
+ #[must_use]
+ pub fn secret(&self) -> &str {
+ &self.secret
+ }
+
+ #[must_use]
+ pub fn metadata(&self) -> &ClientMetadata {
+ &self.metadata
+ }
+}
+
+impl fmt::Debug for ClientUri {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ClientUri")
+ .field("client_public_key", &self.client_public_key)
+ .field("relays", &self.relays)
+ .field("secret", &"[redacted]")
+ .field("metadata", &self.metadata)
+ .finish()
+ }
}
#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum RadrootsNostrConnectUri {
- Bunker(RadrootsNostrConnectBunkerUri),
- Client(RadrootsNostrConnectClientUri),
+pub enum Uri {
+ Bunker(BunkerUri),
+ Client(ClientUri),
}
-impl RadrootsNostrConnectUri {
+impl Uri {
pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> {
+ if value.len() > URI_MAX_BYTES {
+ return Err(RadrootsNostrConnectError::InvalidUri);
+ }
let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl {
- value: value.to_owned(),
+ value: "[redacted NIP-46 URI]".to_owned(),
reason: error.to_string(),
})?;
let host = url
@@ -154,15 +342,15 @@ impl RadrootsNostrConnectUri {
.ok_or(RadrootsNostrConnectError::MissingPublicKey)?;
match url.scheme() {
- RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME => {
+ 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()),
+ "relay" => push_relay(&mut relays, value.as_ref())?,
+ "secret" => set_once(&mut secret, value.into_owned())?,
_ => {}
}
}
@@ -171,29 +359,34 @@ impl RadrootsNostrConnectUri {
return Err(RadrootsNostrConnectError::MissingRelay);
}
- Ok(Self::Bunker(RadrootsNostrConnectBunkerUri {
+ validate_optional_secret(secret.as_deref())?;
+ Ok(Self::Bunker(BunkerUri {
remote_signer_public_key,
relays,
secret,
}))
}
- RADROOTS_NOSTR_CONNECT_URI_SCHEME => {
+ URI_SCHEME => {
let client_public_key = parse_public_key(host)?;
let mut relays = Vec::new();
let mut secret = None;
- let mut metadata = RadrootsNostrConnectClientMetadata::default();
+ let mut metadata = ClientMetadata::default();
+ let mut permissions_seen = false;
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()),
+ "relay" => push_relay(&mut relays, value.as_ref())?,
+ "secret" => set_once(&mut secret, value.into_owned())?,
"perms" => {
- metadata.requested_permissions =
- RadrootsNostrConnectPermissions::from_str(value.as_ref())?;
+ if permissions_seen {
+ return Err(RadrootsNostrConnectError::InvalidUri);
+ }
+ permissions_seen = true;
+ metadata.requested_permissions = Permissions::from_str(value.as_ref())?;
}
- "name" => metadata.name = Some(value.into_owned()),
- "url" => metadata.url = Some(value.into_owned()),
- "image" => metadata.image = Some(value.into_owned()),
+ "name" => set_once(&mut metadata.name, value.into_owned())?,
+ "url" => set_once(&mut metadata.url, value.into_owned())?,
+ "image" => set_once(&mut metadata.image, value.into_owned())?,
_ => {}
}
}
@@ -206,9 +399,10 @@ impl RadrootsNostrConnectUri {
if secret.is_empty() {
return Err(RadrootsNostrConnectError::MissingSecret);
}
+ validate_secret(&secret)?;
let metadata = metadata.normalized()?;
- Ok(Self::Client(RadrootsNostrConnectClientUri {
+ Ok(Self::Client(ClientUri {
client_public_key,
relays,
secret,
@@ -222,7 +416,7 @@ impl RadrootsNostrConnectUri {
}
}
-impl FromStr for RadrootsNostrConnectUri {
+impl FromStr for Uri {
type Err = RadrootsNostrConnectError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
@@ -230,68 +424,154 @@ impl FromStr for RadrootsNostrConnectUri {
}
}
-impl fmt::Display for RadrootsNostrConnectUri {
+impl fmt::Display for Uri {
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::Bunker(uri) => uri.fmt(f),
+ Self::Client(uri) => uri.fmt(f),
+ }
+ }
+}
+
+impl fmt::Display for BunkerUri {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for relay in &self.relays {
+ serializer.append_pair("relay", &relay.to_string());
+ }
+ if let Some(secret) = &self.secret {
+ serializer.append_pair("secret", secret);
+ }
+ let query = serializer.finish();
+ write!(
+ formatter,
+ "{BUNKER_URI_SCHEME}://{}?{query}",
+ self.remote_signer_public_key
+ )
+ }
+}
+
+impl fmt::Display for ClientUri {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for relay in &self.relays {
+ serializer.append_pair("relay", &relay.to_string());
+ }
+ serializer.append_pair("secret", &self.secret);
+ if !self.metadata.requested_permissions.is_empty() {
+ serializer.append_pair("perms", &self.metadata.requested_permissions.to_string());
+ }
+ if let Some(name) = &self.metadata.name {
+ serializer.append_pair("name", name);
+ }
+ if let Some(url) = &self.metadata.url {
+ serializer.append_pair("url", url);
+ }
+ if let Some(image) = &self.metadata.image {
+ serializer.append_pair("image", image);
+ }
+ let query = serializer.finish();
+ write!(
+ formatter,
+ "{URI_SCHEME}://{}?{query}",
+ self.client_public_key
+ )
+ }
+}
+
+macro_rules! impl_uri_serde {
+ ($type:ty, $variant:path) => {
+ impl Serialize for $type {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.to_string())
}
- 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);
+ }
+
+ impl<'de> Deserialize<'de> for $type {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ match Uri::parse(&value).map_err(serde::de::Error::custom)? {
+ $variant(uri) => Ok(uri),
+ _ => Err(serde::de::Error::custom("unexpected NIP-46 URI scheme")),
}
- let query = serializer.finish();
- write!(
- f,
- "{RADROOTS_NOSTR_CONNECT_URI_SCHEME}://{}?{query}",
- uri.client_public_key
- )
}
}
+ };
+}
+
+impl_uri_serde!(BunkerUri, Uri::Bunker);
+impl_uri_serde!(ClientUri, Uri::Client);
+
+impl Serialize for Uri {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for Uri {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
}
}
fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> {
- PublicKey::parse(value)
- .or_else(|_| PublicKey::from_hex(value))
- .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey {
+ radroots_nostr::key::parse_public_key(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(),
- })
+ RelayUrl::parse(value)
+}
+
+fn push_relay(relays: &mut Vec<RelayUrl>, value: &str) -> Result<(), RadrootsNostrConnectError> {
+ let relay = parse_relay_url(value)?;
+ if relays.contains(&relay) {
+ return Ok(());
+ }
+ if relays.len() == RELAY_COUNT_MAX {
+ return Err(RadrootsNostrConnectError::InvalidUri);
+ }
+ relays.push(relay);
+ Ok(())
+}
+
+fn set_once(slot: &mut Option<String>, value: String) -> Result<(), RadrootsNostrConnectError> {
+ if slot.replace(value).is_some() {
+ return Err(RadrootsNostrConnectError::InvalidUri);
+ }
+ Ok(())
+}
+
+fn validate_optional_secret(secret: Option<&str>) -> Result<(), RadrootsNostrConnectError> {
+ match secret {
+ Some("") => Err(RadrootsNostrConnectError::InvalidUri),
+ Some(secret) => validate_secret(secret),
+ None => Ok(()),
+ }
+}
+
+fn validate_secret(secret: &str) -> Result<(), RadrootsNostrConnectError> {
+ if secret.len() > SECRET_MAX_BYTES || secret.chars().any(char::is_control) {
+ return Err(RadrootsNostrConnectError::InvalidUri);
+ }
+ Ok(())
}
fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectError> {
@@ -299,10 +579,10 @@ fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectErro
if normalized.is_empty() {
return Err(invalid_client_metadata("name", "must not be empty"));
}
- if normalized.len() > RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES {
+ if normalized.len() > CLIENT_NAME_MAX_BYTES {
return Err(invalid_client_metadata(
"name",
- format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES} UTF-8 bytes"),
+ format!("must not exceed {CLIENT_NAME_MAX_BYTES} UTF-8 bytes"),
));
}
if normalized.chars().any(char::is_control) {
@@ -318,10 +598,10 @@ fn normalize_client_url(
field: &'static str,
value: &str,
) -> Result<String, RadrootsNostrConnectError> {
- if value.len() > RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES {
+ if value.len() > CLIENT_URL_MAX_BYTES {
return Err(invalid_client_metadata(
field,
- format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES} UTF-8 bytes"),
+ format!("must not exceed {CLIENT_URL_MAX_BYTES} UTF-8 bytes"),
));
}
if value.chars().any(char::is_control) {
@@ -349,9 +629,9 @@ fn normalize_client_url(
fn validate_metadata_size(value: &str) -> Result<(), RadrootsNostrConnectError> {
let received = value.len();
- if received > RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES {
+ if received > CLIENT_METADATA_JSON_MAX_BYTES {
return Err(RadrootsNostrConnectError::ClientMetadataTooLarge {
- max: RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES,
+ max: CLIENT_METADATA_JSON_MAX_BYTES,
received,
});
}
diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs
@@ -6,13 +6,13 @@ use nostr::{
Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent,
};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome,
+ Method, RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome,
RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest,
RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport,
RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError,
- RadrootsNostrConnectMethod, RadrootsNostrConnectRemoteSessionCapability,
- RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
- build_request_event, execute_request_with_transport, parse_response_event,
+ RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, build_request_event,
+ execute_request_with_transport, parse_response_event,
};
use std::collections::VecDeque;
use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, RELAY_PRIMARY_WSS};
@@ -276,14 +276,8 @@ fn ignores_response_from_unexpected_signer_identity() {
RadrootsNostrConnectResponse::Pong,
);
- let outcome = parse_response_event(
- &client_keys,
- &target,
- "req-ping",
- &RadrootsNostrConnectMethod::Ping,
- &response,
- )
- .expect("parse response");
+ let outcome = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &response)
+ .expect("parse response");
assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
}
@@ -298,14 +292,8 @@ fn ignores_non_rpc_kind_from_expected_signer() {
.sign_with_keys(&remote_keys)
.expect("non-rpc response");
- let outcome = parse_response_event(
- &client_keys,
- &target,
- "req-ping",
- &RadrootsNostrConnectMethod::Ping,
- &response,
- )
- .expect("parse response");
+ let outcome = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &response)
+ .expect("parse response");
assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
}
@@ -419,14 +407,8 @@ fn reports_decryption_failure_from_expected_signer() {
.sign_with_keys(&remote_keys)
.expect("malformed response");
- let error = parse_response_event(
- &client_keys,
- &target,
- "req-ping",
- &RadrootsNostrConnectMethod::Ping,
- &malformed,
- )
- .expect_err("decrypt failure");
+ let error = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &malformed)
+ .expect_err("decrypt failure");
assert!(matches!(
error,
@@ -446,14 +428,9 @@ fn parses_auth_challenge_as_progress_without_consuming_final_response() {
RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/continue".to_owned()),
);
- let outcome = parse_response_event(
- &client_keys,
- &target,
- "req-sign",
- &RadrootsNostrConnectMethod::SignEvent,
- &auth,
- )
- .expect("parse auth");
+ let outcome =
+ parse_response_event(&client_keys, &target, "req-sign", &Method::SignEvent, &auth)
+ .expect("parse auth");
assert_eq!(
outcome,
diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs
@@ -3,12 +3,11 @@ mod test_fixtures;
use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR,
- RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod,
- RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission,
- RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ CLIENT_URL_MAX_BYTES, ClientMetadata, Method, Permission, Permissions,
+ RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError,
+ RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
- RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
+ RadrootsNostrConnectResponseEnvelope, Uri,
};
use serde_json::{Value, json};
use std::str::FromStr;
@@ -65,90 +64,80 @@ fn error_method_and_permission_surfaces_cover_public_paths() {
));
let methods = [
- (RadrootsNostrConnectMethod::Connect, "connect"),
- (RadrootsNostrConnectMethod::GetPublicKey, "get_public_key"),
- (
- RadrootsNostrConnectMethod::GetSessionCapability,
- "get_session_capability",
- ),
- (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"),
+ (Method::Connect, "connect"),
+ (Method::GetPublicKey, "get_public_key"),
+ (Method::GetSessionCapability, "get_session_capability"),
+ (Method::SignEvent, "sign_event"),
+ (Method::Nip04Encrypt, "nip04_encrypt"),
+ (Method::Nip04Decrypt, "nip04_decrypt"),
+ (Method::Nip44Encrypt, "nip44_encrypt"),
+ (Method::Nip44Decrypt, "nip44_decrypt"),
+ (Method::Ping, "ping"),
+ (Method::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!(Method::from_str(raw).expect("parse method"), method);
}
assert_eq!(
- RadrootsNostrConnectMethod::from_str("publish_note").expect("custom method"),
- RadrootsNostrConnectMethod::Custom("publish_note".to_owned())
+ Method::from_str("publish_note").expect("custom method"),
+ Method::custom("publish_note").expect("valid custom NIP-46 method")
);
assert!(matches!(
- RadrootsNostrConnectMethod::from_str(" "),
+ Method::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())
+ serde_json::from_str::<Method>("\"do_work\"").expect("deserialize custom method"),
+ Method::custom("do_work").expect("valid custom NIP-46 method")
);
assert!(
- serde_json::from_str::<RadrootsNostrConnectMethod>("123")
+ serde_json::from_str::<Method>("123")
.expect_err("non-string method")
.to_string()
.contains("invalid type")
);
assert!(
- serde_json::from_str::<RadrootsNostrConnectMethod>("\"\"")
+ serde_json::from_str::<Method>("\"\"")
.expect_err("blank method")
.to_string()
.contains("invalid NIP-46 method")
);
- let simple = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping);
+ let simple = Permission::new(Method::Ping);
assert_eq!(simple.to_string(), "ping");
- let parameterized = RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "1059",
- );
+ let parameterized = Permission::with_parameter(Method::SignEvent, "1059");
assert_eq!(parameterized.to_string(), "sign_event:1059");
assert_eq!(
- RadrootsNostrConnectPermission::from_str("sign_event:1059").expect("parse permission"),
+ Permission::from_str("sign_event:1059").expect("parse permission"),
parameterized
);
assert!(matches!(
- RadrootsNostrConnectPermission::from_str(" "),
+ Permission::from_str(" "),
Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == " "
));
assert!(matches!(
- RadrootsNostrConnectPermission::from_str("sign_event:"),
+ Permission::from_str("sign_event:"),
Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
));
assert!(matches!(
- RadrootsNostrConnectPermission::from_str(" :kind"),
+ Permission::from_str(" :kind"),
Err(RadrootsNostrConnectError::InvalidMethod(_))
));
- let empty = RadrootsNostrConnectPermissions::new();
+ let empty = Permissions::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"),
+ Permissions::from_str(" ").expect("empty permissions"),
empty
);
- let permissions = RadrootsNostrConnectPermissions::from(vec![
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
- RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"),
+ let permissions = Permissions::from(vec![
+ Permission::new(Method::Nip44Encrypt),
+ Permission::with_parameter(Method::SignEvent, "13"),
]);
assert_eq!(permissions.to_string(), "nip44_encrypt,sign_event:13");
assert_eq!(
@@ -156,88 +145,60 @@ fn error_method_and_permission_surfaces_cover_public_paths() {
"\"nip44_encrypt,sign_event:13\""
);
assert_eq!(
- serde_json::from_str::<RadrootsNostrConnectPermissions>("\"nip44_encrypt,sign_event:13\"")
+ serde_json::from_str::<Permissions>("\"nip44_encrypt,sign_event:13\"")
.expect("deserialize permissions"),
permissions
);
assert!(
- serde_json::from_str::<RadrootsNostrConnectPermissions>("123")
+ serde_json::from_str::<Permissions>("123")
.expect_err("non-string permissions")
.to_string()
.contains("invalid type")
);
assert!(matches!(
- RadrootsNostrConnectPermissions::from_str("sign_event:,ping"),
+ Permissions::from_str("sign_event:,ping"),
Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
));
- let all_sign_events =
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent);
+ let all_sign_events = Permission::new(Method::SignEvent);
assert!(all_sign_events.matches_sign_event_kind(30402));
- assert!(all_sign_events.matches_request(&RadrootsNostrConnectMethod::SignEvent, None));
- assert!(!all_sign_events.matches_request(&RadrootsNostrConnectMethod::Ping, None));
+ assert!(all_sign_events.matches_request(&Method::SignEvent, None));
+ assert!(!all_sign_events.matches_request(&Method::Ping, None));
- let numeric_sign_event = RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "30402",
- );
- let kind_prefixed_sign_event = RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:30402",
- );
+ let numeric_sign_event = Permission::with_parameter(Method::SignEvent, "30402");
+ let kind_prefixed_sign_event = Permission::with_parameter(Method::SignEvent, "kind:30402");
assert!(numeric_sign_event.matches_sign_event_kind(30402));
assert!(kind_prefixed_sign_event.matches_sign_event_kind(30402));
- assert!(
- numeric_sign_event
- .matches_request(&RadrootsNostrConnectMethod::SignEvent, Some("kind:30402"))
- );
+ assert!(numeric_sign_event.matches_request(&Method::SignEvent, Some("kind:30402")));
assert!(!numeric_sign_event.matches_sign_event_kind(3040));
assert!(
- !RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "130402"
- )
- .matches_sign_event_kind(30402)
+ !Permission::with_parameter(Method::SignEvent, "130402").matches_sign_event_kind(30402)
);
assert!(
- !RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "not-a-kind"
- )
- .matches_request(
- &RadrootsNostrConnectMethod::SignEvent,
- Some("also-not-a-kind")
- )
+ !Permission::with_parameter(Method::SignEvent, "not-a-kind")
+ .matches_request(&Method::SignEvent, Some("also-not-a-kind"))
);
+ assert!(!Permission::with_parameter(Method::SignEvent, "kind:").matches_sign_event_kind(30402));
+ let encrypt_permission =
+ Permission::with_parameter(Method::Nip44Encrypt, test_public_key().to_hex());
assert!(
- !RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:"
- )
- .matches_sign_event_kind(30402)
- );
- let encrypt_permission = RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::Nip44Encrypt,
- test_public_key().to_hex(),
+ encrypt_permission
+ .matches_request(&Method::Nip44Encrypt, Some(&test_public_key().to_hex()))
);
- assert!(encrypt_permission.matches_request(
- &RadrootsNostrConnectMethod::Nip44Encrypt,
- Some(&test_public_key().to_hex())
- ));
- assert!(!encrypt_permission.matches_request(&RadrootsNostrConnectMethod::Nip44Encrypt, None));
+ assert!(!encrypt_permission.matches_request(&Method::Nip44Encrypt, None));
- let typed_permissions = RadrootsNostrConnectPermissions::from(vec![
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
+ let typed_permissions = Permissions::from(vec![
+ Permission::new(Method::Ping),
kind_prefixed_sign_event,
]);
- assert!(typed_permissions.allows_request(&RadrootsNostrConnectMethod::Ping, None));
+ assert!(typed_permissions.allows_request(&Method::Ping, None));
assert!(typed_permissions.allows_sign_event_kind(30402));
assert!(!typed_permissions.allows_sign_event_kind(0));
}
#[test]
fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
- let bunker = RadrootsNostrConnectUri::parse(&format!(
+ let bunker = Uri::parse(&format!(
"bunker://{}?relay={}&foo=bar",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -250,7 +211,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
)));
assert!(!bunker_rendered.contains("secret="));
- let minimal_client: RadrootsNostrConnectUri = format!(
+ let minimal_client: Uri = format!(
"nostrconnect://{}?relay={}&secret=shared",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -264,7 +225,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
assert!(!minimal_client_rendered.contains("url="));
assert!(!minimal_client_rendered.contains("image="));
- let metadata_client = RadrootsNostrConnectUri::parse(&format!(
+ let metadata_client = Uri::parse(&format!(
"nostrconnect://{}?relay={}&secret=shared&perms=ping&name=myc&url={}&image={}&ignored=value",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -282,28 +243,26 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
assert!(metadata_rendered.contains(&format!("image={}", encode_uri_component(&logo_url()))));
assert!(matches!(
- RadrootsNostrConnectUri::parse("not a uri"),
+ Uri::parse("not a uri"),
Err(RadrootsNostrConnectError::InvalidUrl { .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(
- "nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc"
- ),
+ Uri::parse("nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc"),
Err(RadrootsNostrConnectError::MissingPublicKey)
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!("bunker://{}", FIXTURE_ALICE.public_key_hex)),
+ Uri::parse(&format!("bunker://{}", FIXTURE_ALICE.public_key_hex)),
Err(RadrootsNostrConnectError::MissingRelay)
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?secret=abc",
FIXTURE_ALICE.public_key_hex
)),
Err(RadrootsNostrConnectError::MissingRelay)
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay={}",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -311,24 +270,22 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
Err(RadrootsNostrConnectError::MissingSecret)
));
assert!(matches!(
- RadrootsNostrConnectUri::parse("https://example.com"),
+ Uri::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"
- ),
+ Uri::parse("nostrconnect://bad-key?relay=wss%3A%2F%2Frelay.example.com&secret=abc"),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay=http%3A%2F%2Frelay.example.com&secret=abc",
FIXTURE_ALICE.public_key_hex
)),
Err(RadrootsNostrConnectError::InvalidRelayUrl { .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay={}&secret=abc&url=not-a-url",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -336,18 +293,18 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "url", .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"),
+ Uri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"bunker://{}?relay=http%3A%2F%2Frelay.example.com",
FIXTURE_ALICE.public_key_hex
)),
Err(RadrootsNostrConnectError::InvalidRelayUrl { .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay={}&secret=abc&perms=sign_event%3A",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -355,7 +312,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay={}&secret=abc&image=not-a-url",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -363,7 +320,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "image", .. })
));
assert!(matches!(
- RadrootsNostrConnectUri::parse(&format!(
+ Uri::parse(&format!(
"nostrconnect://{}?relay={}&secret=",
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
@@ -374,9 +331,9 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
#[test]
fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
- let empty = RadrootsNostrConnectClientMetadata::default();
+ let empty = ClientMetadata::default();
assert!(empty.is_display_empty());
- let decoded: RadrootsNostrConnectClientMetadata = serde_json::from_value(json!({
+ let decoded: ClientMetadata = serde_json::from_value(json!({
"requested_permissions": "ping",
"name": " client ",
"url": APP_PRIMARY_HTTPS,
@@ -389,21 +346,21 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
Some(format!("{APP_PRIMARY_HTTPS}/").as_str())
);
assert!(
- serde_json::from_value::<RadrootsNostrConnectClientMetadata>(json!({
+ serde_json::from_value::<ClientMetadata>(json!({
"name": "line\nbreak"
}))
.is_err()
);
for metadata in [
- RadrootsNostrConnectClientMetadata {
+ ClientMetadata {
name: Some("client".to_owned()),
..empty.clone()
},
- RadrootsNostrConnectClientMetadata {
+ ClientMetadata {
url: Some(APP_PRIMARY_HTTPS.to_owned()),
..empty.clone()
},
- RadrootsNostrConnectClientMetadata {
+ ClientMetadata {
image: Some(logo_url()),
..empty.clone()
},
@@ -412,7 +369,7 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
}
assert!(matches!(
- RadrootsNostrConnectClientMetadata::from_connect_param("{"),
+ ClientMetadata::from_connect_param("{"),
Err(RadrootsNostrConnectError::InvalidClientMetadata {
field: "payload",
..
@@ -420,15 +377,12 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
));
for (value, field) in [
- (
- "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES + 1),
- "url",
- ),
+ ("x".repeat(CLIENT_URL_MAX_BYTES + 1), "url"),
("https://example.com/\n".to_owned(), "url"),
("https://user@example.com".to_owned(), "url"),
("https://:secret@example.com".to_owned(), "image"),
] {
- let metadata = RadrootsNostrConnectClientMetadata {
+ let metadata = ClientMetadata {
url: (field == "url").then_some(value.clone()),
image: (field == "image").then_some(value),
..empty.clone()
@@ -445,20 +399,17 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
#[test]
fn request_surface_covers_variant_methods_serialization_and_validation() {
- let ping_permission =
- RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new(
- RadrootsNostrConnectMethod::Ping,
- )]);
+ let ping_permission = Permissions::from(vec![Permission::new(Method::Ping)]);
let requests = vec![
(
RadrootsNostrConnectRequest::Connect {
remote_signer_public_key: test_public_key(),
secret: None,
- requested_permissions: RadrootsNostrConnectPermissions::default(),
+ requested_permissions: Permissions::default(),
client_metadata: None,
},
- RadrootsNostrConnectMethod::Connect,
+ Method::Connect,
vec![test_public_key().to_hex()],
),
(
@@ -468,22 +419,22 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
requested_permissions: ping_permission.clone(),
client_metadata: None,
},
- RadrootsNostrConnectMethod::Connect,
+ Method::Connect,
vec![test_public_key().to_hex(), String::new(), "ping".to_owned()],
),
(
RadrootsNostrConnectRequest::GetPublicKey,
- RadrootsNostrConnectMethod::GetPublicKey,
+ Method::GetPublicKey,
Vec::new(),
),
(
RadrootsNostrConnectRequest::GetSessionCapability,
- RadrootsNostrConnectMethod::GetSessionCapability,
+ Method::GetSessionCapability,
Vec::new(),
),
(
RadrootsNostrConnectRequest::SignEvent(unsigned_event()),
- RadrootsNostrConnectMethod::SignEvent,
+ Method::SignEvent,
vec![serde_json::to_string(&unsigned_event()).expect("serialize unsigned event")],
),
(
@@ -491,7 +442,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
public_key: test_public_key(),
plaintext: "hello".to_owned(),
},
- RadrootsNostrConnectMethod::Nip04Encrypt,
+ Method::Nip04Encrypt,
vec![test_public_key().to_hex(), "hello".to_owned()],
),
(
@@ -499,7 +450,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
public_key: test_public_key(),
ciphertext: "cipher".to_owned(),
},
- RadrootsNostrConnectMethod::Nip04Decrypt,
+ Method::Nip04Decrypt,
vec![test_public_key().to_hex(), "cipher".to_owned()],
),
(
@@ -507,7 +458,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
public_key: test_public_key(),
plaintext: "hello".to_owned(),
},
- RadrootsNostrConnectMethod::Nip44Encrypt,
+ Method::Nip44Encrypt,
vec![test_public_key().to_hex(), "hello".to_owned()],
),
(
@@ -515,30 +466,26 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
public_key: test_public_key(),
ciphertext: "cipher".to_owned(),
},
- RadrootsNostrConnectMethod::Nip44Decrypt,
+ Method::Nip44Decrypt,
vec![test_public_key().to_hex(), "cipher".to_owned()],
),
- (
- RadrootsNostrConnectRequest::Ping,
- RadrootsNostrConnectMethod::Ping,
- Vec::new(),
- ),
+ (RadrootsNostrConnectRequest::Ping, Method::Ping, Vec::new()),
(
RadrootsNostrConnectRequest::SwitchRelays,
- RadrootsNostrConnectMethod::SwitchRelays,
+ Method::SwitchRelays,
Vec::new(),
),
(
RadrootsNostrConnectRequest::Logout,
- RadrootsNostrConnectMethod::Logout,
+ Method::Logout,
Vec::new(),
),
(
RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ method: Method::custom("publish_note").expect("valid custom NIP-46 method"),
params: vec!["one".to_owned(), "two".to_owned()],
},
- RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ Method::custom("publish_note").expect("valid custom NIP-46 method"),
vec!["one".to_owned(), "two".to_owned()],
),
];
@@ -548,52 +495,41 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
}
assert_eq!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Connect,
- vec![test_public_key().to_hex()],
- )
- .expect("connect without secret or perms"),
+ RadrootsNostrConnectRequest::from_parts(Method::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(),
+ requested_permissions: Permissions::default(),
client_metadata: None,
}
);
assert_eq!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Connect,
+ Method::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),
- ]),
+ requested_permissions: Permissions::from(vec![Permission::new(Method::Ping),]),
client_metadata: None,
}
);
assert_eq!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::GetPublicKey,
- Vec::new(),
- )
- .expect("get_public_key from parts"),
+ RadrootsNostrConnectRequest::from_parts(Method::GetPublicKey, Vec::new(),)
+ .expect("get_public_key from parts"),
RadrootsNostrConnectRequest::GetPublicKey
);
assert_eq!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::GetSessionCapability,
- Vec::new(),
- )
- .expect("get_session_capability from parts"),
+ RadrootsNostrConnectRequest::from_parts(Method::GetSessionCapability, Vec::new(),)
+ .expect("get_session_capability from parts"),
RadrootsNostrConnectRequest::GetSessionCapability
);
assert_eq!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip04Encrypt,
+ Method::Nip04Encrypt,
vec![test_public_key().to_hex(), "hello".to_owned()],
)
.expect("nip04 encrypt from parts"),
@@ -604,7 +540,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
);
assert_eq!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip04Decrypt,
+ Method::Nip04Decrypt,
vec![test_public_key().to_hex(), "cipher".to_owned()],
)
.expect("nip04 decrypt from parts"),
@@ -615,7 +551,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
);
assert_eq!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip44Encrypt,
+ Method::Nip44Encrypt,
vec![test_public_key().to_hex(), "hello".to_owned()],
)
.expect("nip44 encrypt from parts"),
@@ -626,7 +562,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
);
assert_eq!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip44Decrypt,
+ Method::Nip44Decrypt,
vec![test_public_key().to_hex(), "cipher".to_owned()],
)
.expect("nip44 decrypt from parts"),
@@ -636,70 +572,46 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
}
);
assert_eq!(
- RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Ping, Vec::new())
- .expect("ping from parts"),
+ RadrootsNostrConnectRequest::from_parts(Method::Ping, Vec::new()).expect("ping from parts"),
RadrootsNostrConnectRequest::Ping
);
assert_eq!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::SwitchRelays,
- Vec::new(),
- )
- .expect("switch relays from parts"),
+ RadrootsNostrConnectRequest::from_parts(Method::SwitchRelays, Vec::new(),)
+ .expect("switch relays from parts"),
RadrootsNostrConnectRequest::SwitchRelays
);
for (method, params, expected_error) in [
+ (Method::GetPublicKey, vec!["oops".to_owned()], "no params"),
(
- RadrootsNostrConnectMethod::GetPublicKey,
- vec!["oops".to_owned()],
- "no params",
- ),
- (
- RadrootsNostrConnectMethod::GetSessionCapability,
+ Method::GetSessionCapability,
vec!["oops".to_owned()],
"no params",
),
+ (Method::SignEvent, Vec::new(), "exactly 1 param"),
(
- RadrootsNostrConnectMethod::SignEvent,
- Vec::new(),
- "exactly 1 param",
- ),
- (
- RadrootsNostrConnectMethod::Nip04Encrypt,
+ Method::Nip04Encrypt,
vec!["only-one".to_owned()],
"exactly 2 params",
),
(
- RadrootsNostrConnectMethod::Nip04Decrypt,
+ Method::Nip04Decrypt,
vec!["only-one".to_owned()],
"exactly 2 params",
),
(
- RadrootsNostrConnectMethod::Nip44Encrypt,
+ Method::Nip44Encrypt,
vec!["only-one".to_owned()],
"exactly 2 params",
),
(
- RadrootsNostrConnectMethod::Nip44Decrypt,
+ Method::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",
- ),
- (
- RadrootsNostrConnectMethod::Logout,
- vec!["oops".to_owned()],
- "no params",
- ),
+ (Method::Ping, vec!["oops".to_owned()], "no params"),
+ (Method::SwitchRelays, vec!["oops".to_owned()], "no params"),
+ (Method::Logout, vec!["oops".to_owned()], "no params"),
] {
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(method, params),
@@ -707,27 +619,24 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
));
}
assert!(matches!(
- RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Connect, Vec::new()),
+ RadrootsNostrConnectRequest::from_parts(Method::Connect, Vec::new()),
Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. })
if expected == "1 to 4 params" && received == 0
));
assert!(matches!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Connect,
- vec!["bad-key".to_owned()],
- ),
+ RadrootsNostrConnectRequest::from_parts(Method::Connect, vec!["bad-key".to_owned()],),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Connect,
+ Method::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,
+ Method::Connect,
vec![
test_public_key().to_hex(),
"secret".to_owned(),
@@ -740,36 +649,33 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
if expected == "1 to 4 params" && received == 5
));
assert!(matches!(
- RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::SignEvent,
- vec!["not-json".to_owned()],
- ),
+ RadrootsNostrConnectRequest::from_parts(Method::SignEvent, vec!["not-json".to_owned()],),
Err(RadrootsNostrConnectError::InvalidRequestPayload { .. })
));
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip04Encrypt,
+ Method::Nip04Encrypt,
vec!["bad-key".to_owned(), "hello".to_owned()],
),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip04Decrypt,
+ Method::Nip04Decrypt,
vec!["bad-key".to_owned(), "cipher".to_owned()],
),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip44Encrypt,
+ Method::Nip44Encrypt,
vec!["bad-key".to_owned(), "hello".to_owned()],
),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
));
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Nip44Decrypt,
+ Method::Nip44Decrypt,
vec!["bad-key".to_owned(), "cipher".to_owned()],
),
Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
@@ -778,7 +684,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() {
let custom_message = RadrootsNostrConnectRequestMessage::new(
"req-custom",
RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ method: Method::custom("publish_note").expect("valid custom NIP-46 method"),
params: vec!["a".to_owned()],
},
);
@@ -809,72 +715,69 @@ fn response_surface_covers_success_and_error_paths() {
radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability {
user_public_key: test_public_key(),
relays: vec![relay(RELAY_PRIMARY_WSS), relay(RELAY_SECONDARY_WSS)],
- permissions: RadrootsNostrConnectPermissions::from(vec![
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
- RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:1",
- ),
+ permissions: Permissions::from(vec![
+ Permission::new(Method::Ping),
+ Permission::with_parameter(Method::SignEvent, "kind:1"),
]),
};
let cases = vec![
(
RadrootsNostrConnectResponse::ConnectAcknowledged,
- RadrootsNostrConnectMethod::Connect,
+ Method::Connect,
RadrootsNostrConnectResponse::ConnectAcknowledged,
),
(
RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()),
- RadrootsNostrConnectMethod::Connect,
+ Method::Connect,
RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()),
),
(
RadrootsNostrConnectResponse::UserPublicKey(test_public_key()),
- RadrootsNostrConnectMethod::GetPublicKey,
+ Method::GetPublicKey,
RadrootsNostrConnectResponse::UserPublicKey(test_public_key()),
),
(
RadrootsNostrConnectResponse::PendingConnection,
- RadrootsNostrConnectMethod::GetSessionCapability,
+ Method::GetSessionCapability,
RadrootsNostrConnectResponse::PendingConnection,
),
(
RadrootsNostrConnectResponse::RemoteSessionCapability(
remote_session_capability.clone(),
),
- RadrootsNostrConnectMethod::GetSessionCapability,
+ Method::GetSessionCapability,
RadrootsNostrConnectResponse::RemoteSessionCapability(
remote_session_capability.clone(),
),
),
(
RadrootsNostrConnectResponse::SignedEvent(event.clone()),
- RadrootsNostrConnectMethod::SignEvent,
+ Method::SignEvent,
RadrootsNostrConnectResponse::SignedEvent(event.clone()),
),
(
RadrootsNostrConnectResponse::Pong,
- RadrootsNostrConnectMethod::Ping,
+ Method::Ping,
RadrootsNostrConnectResponse::Pong,
),
(
RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()),
- RadrootsNostrConnectMethod::Nip04Encrypt,
+ Method::Nip04Encrypt,
RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()),
),
(
RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()),
- RadrootsNostrConnectMethod::Nip04Decrypt,
+ Method::Nip04Decrypt,
RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()),
),
(
RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()),
- RadrootsNostrConnectMethod::Nip44Encrypt,
+ Method::Nip44Encrypt,
RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()),
),
(
RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()),
- RadrootsNostrConnectMethod::Nip44Decrypt,
+ Method::Nip44Decrypt,
RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()),
),
(
@@ -882,7 +785,7 @@ fn response_surface_covers_success_and_error_paths() {
relay(RELAY_SECONDARY_WSS),
relay(RELAY_TERTIARY_WSS),
]),
- RadrootsNostrConnectMethod::SwitchRelays,
+ Method::SwitchRelays,
RadrootsNostrConnectResponse::RelayList(vec![
relay(RELAY_SECONDARY_WSS),
relay(RELAY_TERTIARY_WSS),
@@ -890,7 +793,7 @@ fn response_surface_covers_success_and_error_paths() {
),
(
RadrootsNostrConnectResponse::RelayListUnchanged,
- RadrootsNostrConnectMethod::SwitchRelays,
+ Method::SwitchRelays,
RadrootsNostrConnectResponse::RelayListUnchanged,
),
];
@@ -922,17 +825,14 @@ fn response_surface_covers_success_and_error_paths() {
.into_envelope("req-auth")
.expect("serialize auth_url");
assert_eq!(
- RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
- auth_envelope,
- )
- .expect("parse auth_url"),
+ RadrootsNostrConnectResponse::from_envelope(&Method::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()),
+ &Method::custom("publish_note").expect("valid custom NIP-46 method"),
RadrootsNostrConnectResponseEnvelope {
id: "req-custom".to_owned(),
result: Some(json!("ok")),
@@ -947,7 +847,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ &Method::custom("publish_note").expect("valid custom NIP-46 method"),
RadrootsNostrConnectResponseEnvelope {
id: "req-custom".to_owned(),
result: Some(json!({"ok": true})),
@@ -962,7 +862,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetPublicKey,
+ &Method::GetPublicKey,
RadrootsNostrConnectResponseEnvelope {
id: "req-pending".to_owned(),
result: None,
@@ -974,7 +874,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
RadrootsNostrConnectResponseEnvelope {
id: "req-pending-capability".to_owned(),
result: None,
@@ -986,7 +886,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetPublicKey,
+ &Method::GetPublicKey,
RadrootsNostrConnectResponseEnvelope {
id: "req-nonpending-public-key".to_owned(),
result: None,
@@ -1001,7 +901,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
RadrootsNostrConnectResponseEnvelope {
id: "req-capability-error-with-result".to_owned(),
result: Some(json!({"code": "retry"})),
@@ -1016,7 +916,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
RadrootsNostrConnectResponseEnvelope {
id: "req-capability-invalid-result".to_owned(),
result: Some(json!({"permissions": "ping"})),
@@ -1028,7 +928,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
RadrootsNostrConnectResponseEnvelope {
id: "req-capability-string-result".to_owned(),
result: Some(json!(
@@ -1043,7 +943,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
RadrootsNostrConnectResponseEnvelope {
id: "req-capability-invalid-string".to_owned(),
result: Some(json!("{")),
@@ -1055,7 +955,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Ping,
+ &Method::Ping,
RadrootsNostrConnectResponseEnvelope {
id: "req-error".to_owned(),
result: Some(json!("partial")),
@@ -1070,7 +970,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-event".to_owned(),
result: Some(serde_json::to_value(&event).expect("event value")),
@@ -1082,7 +982,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!("null")),
@@ -1094,7 +994,7 @@ fn response_surface_covers_success_and_error_paths() {
);
assert_eq!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!(format!("[\"{RELAY_SECONDARY_WSS}\"]"))),
@@ -1111,7 +1011,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-auth".to_owned(),
result: Some(json!("auth_url")),
@@ -1122,7 +1022,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetPublicKey,
+ &Method::GetPublicKey,
RadrootsNostrConnectResponseEnvelope {
id: "req-key".to_owned(),
result: Some(json!("bad-key")),
@@ -1133,7 +1033,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Connect,
+ &Method::Connect,
RadrootsNostrConnectResponseEnvelope {
id: "req-connect".to_owned(),
result: None,
@@ -1144,7 +1044,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetPublicKey,
+ &Method::GetPublicKey,
RadrootsNostrConnectResponseEnvelope {
id: "req-key".to_owned(),
result: None,
@@ -1155,7 +1055,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Ping,
+ &Method::Ping,
RadrootsNostrConnectResponseEnvelope {
id: "req-ping".to_owned(),
result: Some(json!("nope")),
@@ -1166,7 +1066,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Ping,
+ &Method::Ping,
RadrootsNostrConnectResponseEnvelope {
id: "req-ping".to_owned(),
result: None,
@@ -1177,7 +1077,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Nip04Encrypt,
+ &Method::Nip04Encrypt,
RadrootsNostrConnectResponseEnvelope {
id: "req-nip04".to_owned(),
result: Some(json!(5)),
@@ -1188,7 +1088,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Nip04Encrypt,
+ &Method::Nip04Encrypt,
RadrootsNostrConnectResponseEnvelope {
id: "req-nip04".to_owned(),
result: None,
@@ -1199,7 +1099,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-event".to_owned(),
result: Some(json!("not-json")),
@@ -1210,7 +1110,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-event".to_owned(),
result: Some(json!(5)),
@@ -1221,7 +1121,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-event".to_owned(),
result: None,
@@ -1232,7 +1132,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Nip04Decrypt,
+ &Method::Nip04Decrypt,
RadrootsNostrConnectResponseEnvelope {
id: "req-nip04d".to_owned(),
result: None,
@@ -1243,7 +1143,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Nip44Encrypt,
+ &Method::Nip44Encrypt,
RadrootsNostrConnectResponseEnvelope {
id: "req-nip44e".to_owned(),
result: None,
@@ -1254,7 +1154,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Nip44Decrypt,
+ &Method::Nip44Decrypt,
RadrootsNostrConnectResponseEnvelope {
id: "req-nip44d".to_owned(),
result: None,
@@ -1265,7 +1165,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!("[invalid")),
@@ -1276,7 +1176,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!([1])),
@@ -1287,7 +1187,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!(["http://relay.example.com"])),
@@ -1298,7 +1198,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(json!(5)),
@@ -1309,7 +1209,7 @@ fn response_surface_covers_success_and_error_paths() {
));
assert!(matches!(
RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Logout,
+ &Method::Logout,
RadrootsNostrConnectResponseEnvelope {
id: "req-logout".to_owned(),
result: Some(json!("not-ack")),
@@ -1327,12 +1227,9 @@ fn pending_connection_poll_outcome_uses_typed_variants() {
radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability {
user_public_key: test_public_key(),
relays: vec![relay(RELAY_PRIMARY_WSS), relay(RELAY_SECONDARY_WSS)],
- permissions: RadrootsNostrConnectPermissions::from(vec![
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
- RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:1",
- ),
+ permissions: Permissions::from(vec![
+ Permission::new(Method::Ping),
+ Permission::with_parameter(Method::SignEvent, "kind:1"),
]),
};
diff --git a/crates/nostr_connect/tests/package_boundary.rs b/crates/nostr_connect/tests/package_boundary.rs
@@ -1,8 +1,11 @@
use std::collections::BTreeSet;
const MANIFEST: &str = include_str!("../Cargo.toml");
+const METHOD: &str = include_str!("../src/method.rs");
+const PERMISSION: &str = include_str!("../src/permission.rs");
const ROOT: &str = include_str!("../src/lib.rs");
const SERVER: &str = include_str!("../src/server.rs");
+const URI: &str = include_str!("../src/uri.rs");
#[test]
fn manifest_has_final_identity_feature_vocabulary_and_radroots_dependencies() {
@@ -66,6 +69,35 @@ fn crate_root_contains_the_approved_module_skeleton() {
);
}
+#[test]
+fn uri_method_and_permission_types_use_canonical_owners_and_names() {
+ for root_export in [
+ "pub use method::Method;",
+ "pub use permission::Permission;",
+ "pub use uri::{BunkerUri, ClientUri};",
+ ] {
+ assert!(ROOT.contains(root_export), "missing `{root_export}`");
+ }
+ for forbidden in [
+ "pub enum RadrootsNostrConnectMethod",
+ "pub struct RadrootsNostrConnectPermission",
+ "pub struct RadrootsNostrConnectPermissions",
+ ] {
+ assert!(!METHOD.contains(forbidden));
+ assert!(!PERMISSION.contains(forbidden));
+ }
+ for forbidden in [
+ "pub struct RadrootsNostrConnectBunkerUri",
+ "pub struct RadrootsNostrConnectClientUri",
+ "pub enum RadrootsNostrConnectUri",
+ "use nostr::{PublicKey",
+ ] {
+ assert!(!URI.contains(forbidden), "URI source retains `{forbidden}`");
+ }
+ assert!(URI.contains("use radroots_identity::PublicKey;"));
+ assert!(URI.contains("radroots_nostr::key::parse_public_key"));
+}
+
fn table_keys<'a>(source: &'a str, header: &str) -> BTreeSet<&'a str> {
let Some((_, tail)) = source.split_once(header) else {
panic!("manifest is missing {header}");
diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs
@@ -3,12 +3,10 @@ mod test_fixtures;
use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES,
- RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR,
- RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod,
- RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
- RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
- RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
+ CLIENT_METADATA_JSON_MAX_BYTES, CLIENT_NAME_MAX_BYTES, ClientMetadata, Method, Permission,
+ Permissions, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError,
+ RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope, Uri,
};
use serde_json::{Value, json};
use test_fixtures::{
@@ -20,6 +18,11 @@ fn test_public_key() -> PublicKey {
PublicKey::parse(FIXTURE_ALICE.public_key_hex).expect("public key")
}
+fn test_identity_public_key() -> radroots_identity::PublicKey {
+ radroots_identity::PublicKey::from_hex(FIXTURE_ALICE.public_key_hex)
+ .expect("identity public key")
+}
+
fn test_keys() -> Keys {
let secret_key = SecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).expect("secret key");
Keys::new(secret_key)
@@ -41,12 +44,9 @@ fn remote_session_capability()
RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay 1"),
RelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 2"),
],
- permissions: RadrootsNostrConnectPermissions::from(vec![
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
- RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:1",
- ),
+ permissions: Permissions::from(vec![
+ Permission::new(Method::Ping),
+ Permission::with_parameter(Method::SignEvent, "kind:1"),
]),
}
}
@@ -61,29 +61,29 @@ fn parses_client_uri_with_current_spec_query_fields() {
encode_uri_component(APP_PRIMARY_HTTPS),
encode_uri_component(&logo_url()),
);
- let parsed = RadrootsNostrConnectUri::parse(&uri).expect("parse client uri");
+ let parsed = Uri::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"));
+ Uri::Client(client) => {
+ assert_eq!(client.client_public_key(), test_identity_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",
- ),
+ client.metadata().requested_permissions,
+ Permissions::from(vec![
+ Permission::new(Method::Nip44Encrypt,),
+ Permission::with_parameter(Method::SignEvent, "1059",),
])
);
assert_eq!(
- client.metadata.url.as_deref(),
+ client.metadata().url.as_deref(),
Some(format!("{APP_PRIMARY_HTTPS}/").as_str())
);
- assert_eq!(client.metadata.image.as_deref(), Some(logo_url().as_str()));
+ assert_eq!(
+ client.metadata().image.as_deref(),
+ Some(logo_url().as_str())
+ );
}
other => panic!("expected client uri, got {other:?}"),
}
@@ -96,9 +96,9 @@ fn parses_bunker_uri_and_roundtrips() {
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
);
- let parsed = RadrootsNostrConnectUri::parse(&source).expect("parse bunker uri");
+ let parsed = Uri::parse(&source).expect("parse bunker uri");
let rendered = parsed.to_string();
- let reparsed = RadrootsNostrConnectUri::parse(&rendered).expect("reparse bunker uri");
+ let reparsed = Uri::parse(&rendered).expect("reparse bunker uri");
assert_eq!(parsed, reparsed);
}
@@ -109,19 +109,19 @@ fn rejects_client_uri_without_required_secret() {
FIXTURE_ALICE.public_key_hex,
encode_uri_component(RELAY_PRIMARY_WSS),
);
- assert!(RadrootsNostrConnectUri::parse(&source).is_err());
+ assert!(Uri::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 permissions = Permissions::from(vec![
+ Permission::new(Method::Nip44Encrypt),
+ Permission::with_parameter(Method::SignEvent, "13"),
]);
let rendered = permissions.to_string();
assert_eq!(rendered, "nip44_encrypt,sign_event:13");
- let reparsed: RadrootsNostrConnectPermissions = rendered.parse().expect("parse permissions");
+ let reparsed: Permissions = rendered.parse().expect("parse permissions");
assert_eq!(permissions, reparsed);
}
@@ -130,12 +130,9 @@ 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",
- ),
+ requested_permissions: Permissions::from(vec![
+ Permission::new(Method::Nip44Encrypt),
+ Permission::with_parameter(Method::SignEvent, "1059"),
]),
client_metadata: None,
};
@@ -164,9 +161,9 @@ fn connect_request_roundtrips_client_metadata_in_fourth_parameter() {
let request = RadrootsNostrConnectRequest::Connect {
remote_signer_public_key: test_public_key(),
secret: None,
- requested_permissions: RadrootsNostrConnectPermissions::default(),
- client_metadata: Some(RadrootsNostrConnectClientMetadata {
- requested_permissions: RadrootsNostrConnectPermissions::default(),
+ requested_permissions: Permissions::default(),
+ client_metadata: Some(ClientMetadata {
+ requested_permissions: Permissions::default(),
name: Some(" My Client ".to_owned()),
url: Some(APP_PRIMARY_HTTPS.to_owned()),
image: Some(logo_url()),
@@ -219,7 +216,7 @@ fn logout_request_and_acknowledgement_roundtrip() {
);
let response = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::Logout,
+ &Method::Logout,
RadrootsNostrConnectResponseEnvelope {
id: "req-logout".to_owned(),
result: Some(Value::String("ack".to_owned())),
@@ -257,11 +254,11 @@ fn rejects_invalid_client_metadata() {
encode_uri_component(RELAY_PRIMARY_WSS),
encode_uri_component("file:///tmp/client"),
);
- assert!(RadrootsNostrConnectUri::parse(&invalid_scheme).is_err());
+ assert!(Uri::parse(&invalid_scheme).is_err());
- let oversized_name = RadrootsNostrConnectClientMetadata {
- requested_permissions: RadrootsNostrConnectPermissions::default(),
- name: Some("a".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES + 1)),
+ let oversized_name = ClientMetadata {
+ requested_permissions: Permissions::default(),
+ name: Some("a".repeat(CLIENT_NAME_MAX_BYTES + 1)),
url: None,
image: None,
};
@@ -270,10 +267,10 @@ fn rejects_invalid_client_metadata() {
Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "name", .. })
));
- let oversized_payload = "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES + 1);
+ let oversized_payload = "x".repeat(CLIENT_METADATA_JSON_MAX_BYTES + 1);
assert!(matches!(
RadrootsNostrConnectRequest::from_parts(
- RadrootsNostrConnectMethod::Connect,
+ Method::Connect,
vec![
test_public_key().to_hex(),
String::new(),
@@ -319,11 +316,9 @@ fn switch_relays_response_accepts_array_or_null() {
result: Some(json!([RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS])),
error: None,
};
- let parsed = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
- relays_response,
- )
- .expect("parse relay list");
+ let parsed =
+ RadrootsNostrConnectResponse::from_envelope(&Method::SwitchRelays, relays_response)
+ .expect("parse relay list");
assert_eq!(
parsed,
RadrootsNostrConnectResponse::RelayList(vec![
@@ -333,7 +328,7 @@ fn switch_relays_response_accepts_array_or_null() {
);
let unchanged = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SwitchRelays,
+ &Method::SwitchRelays,
RadrootsNostrConnectResponseEnvelope {
id: "req-switch".to_owned(),
result: Some(Value::Null),
@@ -361,7 +356,7 @@ fn get_session_capability_request_and_response_roundtrip() {
.into_envelope("resp-cap")
.expect("serialize response");
let decoded_response = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetSessionCapability,
+ &Method::GetSessionCapability,
response_envelope,
)
.expect("deserialize response");
@@ -374,7 +369,7 @@ fn get_session_capability_request_and_response_roundtrip() {
#[test]
fn auth_url_response_parses_from_result_and_error_fields() {
let response = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::SignEvent,
+ &Method::SignEvent,
RadrootsNostrConnectResponseEnvelope {
id: "req-auth".to_owned(),
result: Some(json!("auth_url")),
@@ -392,7 +387,7 @@ fn auth_url_response_parses_from_result_and_error_fields() {
#[test]
fn get_public_key_pending_response_parses_as_typed_pending_connection() {
let response = RadrootsNostrConnectResponse::from_envelope(
- &RadrootsNostrConnectMethod::GetPublicKey,
+ &Method::GetPublicKey,
RadrootsNostrConnectResponseEnvelope {
id: "req-pending".to_owned(),
result: None,
@@ -415,11 +410,8 @@ fn sign_event_response_roundtrips_signed_event_json_string() {
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");
+ let parsed = RadrootsNostrConnectResponse::from_envelope(&Method::SignEvent, envelope)
+ .expect("parse signed event response");
assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event));
}
@@ -458,29 +450,29 @@ fn checked_in_current_session_vectors_match_protocol_behavior() {
"nip46.metadata.invalid" => {
let count = input["count"].as_u64().expect("metadata repeat count") as usize;
let repeat = input["repeat"].as_str().expect("metadata repeat value");
- let metadata = RadrootsNostrConnectClientMetadata {
+ let metadata = ClientMetadata {
name: Some(repeat.repeat(count)),
- ..RadrootsNostrConnectClientMetadata::default()
+ ..ClientMetadata::default()
};
let error = metadata.normalized().expect_err("invalid metadata vector");
assert_vector_error(id, expected, error);
}
"nip46.uri.valid" => {
let uri = input["uri"].as_str().expect("NIP-46 URI");
- let parsed = RadrootsNostrConnectUri::parse(uri)
- .unwrap_or_else(|error| panic!("{id}: parse URI: {error}"));
+ let parsed =
+ Uri::parse(uri).unwrap_or_else(|error| panic!("{id}: parse URI: {error}"));
assert_uri_vector(id, parsed, expected);
}
"nip46.uri.invalid" => {
let uri = input["uri"].as_str().expect("NIP-46 URI");
- let error = RadrootsNostrConnectUri::parse(uri).expect_err("invalid URI vector");
+ let error = Uri::parse(uri).expect_err("invalid URI vector");
assert_vector_error(id, expected, error);
}
"nip46.response.valid" => {
let method = input["method"]
.as_str()
.expect("response method")
- .parse::<RadrootsNostrConnectMethod>()
+ .parse::<Method>()
.expect("typed response method");
let envelope: RadrootsNostrConnectResponseEnvelope =
serde_json::from_value(input["envelope"].clone())
@@ -499,7 +491,7 @@ fn checked_in_current_session_vectors_match_protocol_behavior() {
let method = input["method"]
.as_str()
.expect("response method")
- .parse::<RadrootsNostrConnectMethod>()
+ .parse::<Method>()
.expect("typed response method");
let envelope: RadrootsNostrConnectResponseEnvelope =
serde_json::from_value(input["envelope"].clone())
@@ -513,7 +505,7 @@ fn checked_in_current_session_vectors_match_protocol_behavior() {
}
}
-fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value) {
+fn assert_uri_vector(id: &str, parsed: Uri, expected: &Value) {
let expected_relays = expected["relays"]
.as_array()
.expect("expected relays")
@@ -522,42 +514,42 @@ fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value
.collect::<Vec<_>>();
match parsed {
- RadrootsNostrConnectUri::Bunker(uri) => {
+ Uri::Bunker(uri) => {
assert_eq!(expected["variant"], "bunker", "{id}");
let relays = uri
- .relays
+ .relays()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(relays, expected_relays, "{id}");
- assert_eq!(uri.secret.as_deref(), expected["secret"].as_str(), "{id}");
+ assert_eq!(uri.secret(), expected["secret"].as_str(), "{id}");
}
- RadrootsNostrConnectUri::Client(uri) => {
+ Uri::Client(uri) => {
assert_eq!(expected["variant"], "nostrconnect", "{id}");
let relays = uri
- .relays
+ .relays()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(relays, expected_relays, "{id}");
- assert_eq!(uri.secret, expected["secret"].as_str().expect("secret"));
+ assert_eq!(uri.secret(), expected["secret"].as_str().expect("secret"));
assert_eq!(
- uri.metadata.name.as_deref(),
+ uri.metadata().name.as_deref(),
expected["metadata"]["name"].as_str(),
"{id}"
);
assert_eq!(
- uri.metadata.url.as_deref(),
+ uri.metadata().url.as_deref(),
expected["metadata"]["url"].as_str(),
"{id}"
);
assert_eq!(
- uri.metadata.image.as_deref(),
+ uri.metadata().image.as_deref(),
expected["metadata"]["image"].as_str(),
"{id}"
);
assert_eq!(
- uri.metadata.requested_permissions.to_string(),
+ uri.metadata().requested_permissions.to_string(),
expected["metadata"]["permissions"]
.as_str()
.expect("permissions"),
diff --git a/crates/nostr_connect/tests/type_contract.rs b/crates/nostr_connect/tests/type_contract.rs
@@ -0,0 +1,140 @@
+#[path = "../src/test_fixtures.rs"]
+mod test_fixtures;
+
+use std::str::FromStr;
+
+use radroots_nostr_connect::{
+ BunkerUri, ClientUri, Method, Permission,
+ permission::{PERMISSION_COUNT_MAX, PERMISSION_PARAMETER_MAX_BYTES, Permissions},
+ uri::{ClientMetadata, Uri},
+};
+
+use test_fixtures::{FIXTURE_ALICE, RELAY_PRIMARY_WSS};
+
+#[test]
+fn methods_and_permissions_are_bounded_and_canonical() {
+ assert_eq!(
+ Method::custom("publish_note").expect("custom method"),
+ Method::from_str("publish_note").expect("parsed custom method")
+ );
+ for invalid in [
+ "",
+ "PublishNote",
+ "publish-note",
+ &"x".repeat(radroots_nostr_connect::method::METHOD_MAX_BYTES + 1),
+ ] {
+ assert!(Method::from_str(invalid).is_err(), "accepted `{invalid}`");
+ }
+ assert!(Method::custom("not canonical").is_err());
+
+ let sign_event =
+ Permission::try_with_parameter(Method::SignEvent, "kind:1").expect("bounded permission");
+ assert_eq!(sign_event.method(), &Method::SignEvent);
+ assert_eq!(sign_event.parameter(), Some("kind:1"));
+ assert!(
+ Permission::try_with_parameter(
+ Method::SignEvent,
+ "x".repeat(PERMISSION_PARAMETER_MAX_BYTES + 1),
+ )
+ .is_err()
+ );
+
+ let permissions = Permissions::try_from_vec(vec![
+ sign_event.clone(),
+ Permission::new(Method::Ping),
+ sign_event,
+ ])
+ .expect("canonical permissions");
+ assert_eq!(permissions.to_string(), "ping,sign_event:kind:1");
+ assert_eq!(permissions.as_slice().len(), 2);
+ assert!(
+ Permissions::try_from_vec(
+ (0..=PERMISSION_COUNT_MAX)
+ .map(|index| {
+ Permission::try_with_parameter(Method::SignEvent, index.to_string())
+ .expect("bounded parameter")
+ })
+ .collect(),
+ )
+ .is_err()
+ );
+}
+
+#[test]
+fn uri_keys_are_identity_owned_and_secret_diagnostics_are_redacted() {
+ let encoded_relay: String =
+ url::form_urlencoded::byte_serialize(RELAY_PRIMARY_WSS.as_bytes()).collect();
+ let source = format!(
+ "bunker://{}?relay={encoded_relay}&secret=do-not-log",
+ FIXTURE_ALICE.npub
+ );
+ let uri = Uri::parse(&source).expect("bunker URI");
+ let Uri::Bunker(bunker) = &uri else {
+ panic!("expected bunker URI");
+ };
+
+ let key: radroots_identity::PublicKey = bunker.remote_signer_public_key();
+ assert_eq!(key.to_hex(), FIXTURE_ALICE.public_key_hex);
+ assert_eq!(bunker.relays().len(), 1);
+ assert_eq!(bunker.secret(), Some("do-not-log"));
+ assert!(!format!("{bunker:?}").contains("do-not-log"));
+ assert!(!format!("{uri:?}").contains("do-not-log"));
+
+ let canonical = uri.to_string();
+ assert!(canonical.starts_with(&format!("bunker://{}?", FIXTURE_ALICE.public_key_hex)));
+ let serialized = serde_json::to_string(&uri).expect("serialize URI");
+ let decoded: Uri = serde_json::from_str(&serialized).expect("deserialize URI");
+ assert_eq!(decoded, uri);
+
+ let duplicate = format!(
+ "bunker://{}?relay={encoded_relay}&secret=one&secret=two",
+ FIXTURE_ALICE.public_key_hex
+ );
+ assert!(Uri::parse(&duplicate).is_err());
+ let duplicate_relay = format!(
+ "bunker://{}?relay={encoded_relay}&relay={encoded_relay}",
+ FIXTURE_ALICE.public_key_hex
+ );
+ let Uri::Bunker(deduplicated) = Uri::parse(&duplicate_relay).expect("deduplicated relay")
+ else {
+ panic!("expected bunker URI");
+ };
+ assert_eq!(deduplicated.relays().len(), 1);
+ assert!(
+ Uri::parse(&format!(
+ "bunker://{}?relay={encoded_relay}&secret={}",
+ FIXTURE_ALICE.public_key_hex,
+ "x".repeat(radroots_nostr_connect::uri::SECRET_MAX_BYTES + 1)
+ ))
+ .is_err()
+ );
+ let malformed = "not a uri?secret=do-not-log";
+ let error = Uri::parse(malformed).expect_err("malformed URI");
+ assert!(!error.to_string().contains("do-not-log"));
+ assert!(!format!("{error:?}").contains("do-not-log"));
+
+ let _: &BunkerUri = bunker;
+ let _client_type: Option<&ClientUri> = None;
+}
+
+#[test]
+fn client_metadata_serialization_revalidates_public_fields() {
+ let metadata = ClientMetadata::new()
+ .with_name(" My Client ")
+ .expect("name")
+ .with_url("https://client.example.com")
+ .expect("URL")
+ .with_requested_permissions(
+ Permissions::try_from_vec(vec![Permission::new(Method::Ping)]).expect("permissions"),
+ );
+ assert_eq!(metadata.name(), Some("My Client"));
+ assert_eq!(metadata.url(), Some("https://client.example.com/"));
+ assert!(metadata.image().is_none());
+ assert_eq!(metadata.requested_permissions().to_string(), "ping");
+
+ let invalid = ClientMetadata {
+ name: Some("x".repeat(radroots_nostr_connect::uri::CLIENT_NAME_MAX_BYTES + 1)),
+ ..ClientMetadata::default()
+ };
+ assert!(serde_json::to_string(&invalid).is_err());
+}
diff --git a/crates/nostr_signer/Cargo.toml b/crates/nostr_signer/Cargo.toml
@@ -22,7 +22,7 @@ radroots_identity = { workspace = true, default-features = false, features = [
"serde",
"std",
] }
-radroots_nostr = { workspace = true, features = ["std"] }
+radroots_nostr = { workspace = true, features = ["events", "std"] }
radroots_nostr_connect = { workspace = true }
radroots_runtime = { workspace = true }
radroots_sql_core = { workspace = true, optional = true }
diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs
@@ -430,12 +430,13 @@ mod tests {
));
assert!(!request_allowed_by_permissions(
&vec![RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::Custom("do_thing".into()),
+ RadrootsNostrConnectMethod::custom("do_thing").expect("valid custom NIP-46 method"),
"scoped",
)]
.into(),
&RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("do_thing".into()),
+ method: RadrootsNostrConnectMethod::custom("do_thing")
+ .expect("valid custom NIP-46 method"),
params: vec!["value".into()],
},
));
@@ -445,11 +446,11 @@ mod tests {
));
assert!(permission_matches(
&RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::Custom("scoped".into()),
+ RadrootsNostrConnectMethod::custom("scoped").expect("valid custom NIP-46 method"),
"alpha",
),
&RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::Custom("scoped".into()),
+ RadrootsNostrConnectMethod::custom("scoped").expect("valid custom NIP-46 method"),
"alpha",
),
));
@@ -471,7 +472,8 @@ mod tests {
let switch_relays = RadrootsNostrConnectRequest::SwitchRelays;
let sign_event = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7));
let custom = RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("do_thing".into()),
+ method: RadrootsNostrConnectMethod::custom("do_thing")
+ .expect("valid custom NIP-46 method"),
params: vec!["a".into()],
};
diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs
@@ -1570,7 +1570,8 @@ mod tests {
request_message(
"req-custom",
RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ method: RadrootsNostrConnectMethod::custom("publish_note")
+ .expect("valid custom NIP-46 method"),
params: vec![],
},
),
@@ -1876,7 +1877,8 @@ mod tests {
let custom = request_message(
"req-eval-custom",
RadrootsNostrConnectRequest::Custom {
- method: RadrootsNostrConnectMethod::Custom("do_work".to_owned()),
+ method: RadrootsNostrConnectMethod::custom("do_work")
+ .expect("valid custom NIP-46 method"),
params: vec![],
},
);