commit fae39d7c7bd860858ef9115a215f9458c54d32fc
parent 4801bd8e6392fe036bc4cfe40621045d34279918
Author: triesap <tyson@radroots.org>
Date: Thu, 2 Apr 2026 19:10:35 +0000
nostr-connect: add approved remote session capability
Diffstat:
4 files changed, 148 insertions(+), 3 deletions(-)
diff --git a/crates/nostr-connect/src/lib.rs b/crates/nostr-connect/src/lib.rs
@@ -10,7 +10,8 @@ pub mod prelude {
pub use crate::error::RadrootsNostrConnectError;
pub use crate::message::{
RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND,
- RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectPendingConnectionPollOutcome,
+ RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope,
};
diff --git a/crates/nostr-connect/src/message.rs b/crates/nostr-connect/src/message.rs
@@ -9,6 +9,13 @@ use url::Url;
pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133;
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrConnectRemoteSessionCapability {
+ pub user_public_key: PublicKey,
+ pub relays: Vec<RelayUrl>,
+ pub permissions: RadrootsNostrConnectPermissions,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadrootsNostrConnectRequest {
Connect {
@@ -17,6 +24,7 @@ pub enum RadrootsNostrConnectRequest {
requested_permissions: RadrootsNostrConnectPermissions,
},
GetPublicKey,
+ GetSessionCapability,
SignEvent(UnsignedEvent),
Nip04Encrypt {
public_key: PublicKey,
@@ -47,6 +55,7 @@ impl RadrootsNostrConnectRequest {
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,
@@ -75,7 +84,9 @@ impl RadrootsNostrConnectRequest {
}
params
}
- Self::GetPublicKey | Self::Ping | Self::SwitchRelays => Vec::new(),
+ Self::GetPublicKey | Self::GetSessionCapability | Self::Ping | Self::SwitchRelays => {
+ Vec::new()
+ }
Self::SignEvent(unsigned_event) => vec![unsigned_event.as_json()],
Self::Nip04Encrypt {
public_key,
@@ -129,6 +140,10 @@ impl RadrootsNostrConnectRequest {
expect_param_count(&method, ¶ms, 0)?;
Ok(Self::GetPublicKey)
}
+ RadrootsNostrConnectMethod::GetSessionCapability => {
+ expect_param_count(&method, ¶ms, 0)?;
+ Ok(Self::GetSessionCapability)
+ }
RadrootsNostrConnectMethod::SignEvent => {
expect_param_count(&method, ¶ms, 1)?;
let unsigned_event = serde_json::from_str(¶ms[0]).map_err(|error| {
@@ -247,6 +262,7 @@ pub const RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR: &str = "connection is
pub enum RadrootsNostrConnectPendingConnectionPollOutcome {
PendingApproval,
Approved(PublicKey),
+ ApprovedCapability(RadrootsNostrConnectRemoteSessionCapability),
Rejected { message: String },
AuthChallenge { url: String },
UnexpectedResponse { response: String },
@@ -258,6 +274,7 @@ pub enum RadrootsNostrConnectResponse {
ConnectSecretEcho(String),
PendingConnection,
UserPublicKey(PublicKey),
+ RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability),
SignedEvent(Event),
Pong,
Nip04Encrypt(String),
@@ -288,6 +305,9 @@ impl RadrootsNostrConnectResponse {
Self::UserPublicKey(public_key) => {
RadrootsNostrConnectPendingConnectionPollOutcome::Approved(public_key)
}
+ Self::RemoteSessionCapability(capability) => {
+ RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability)
+ }
Self::Error { error, .. } => {
RadrootsNostrConnectPendingConnectionPollOutcome::Rejected { message: error }
}
@@ -326,6 +346,16 @@ impl RadrootsNostrConnectResponse {
result: Some(Value::String(public_key.to_hex())),
error: None,
},
+ Self::RemoteSessionCapability(capability) => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(serde_json::to_value(capability).map_err(|error| {
+ RadrootsNostrConnectError::InvalidResponsePayload {
+ method: RadrootsNostrConnectMethod::GetSessionCapability.to_string(),
+ reason: error.to_string(),
+ }
+ })?),
+ error: None,
+ },
Self::SignedEvent(event) => RadrootsNostrConnectResponseEnvelope {
id,
result: Some(Value::String(event.as_json())),
@@ -424,6 +454,9 @@ impl RadrootsNostrConnectResponse {
let result = expect_string_result(method, envelope.result)?;
Ok(Self::UserPublicKey(parse_public_key(&result)?))
}
+ RadrootsNostrConnectMethod::GetSessionCapability => Ok(Self::RemoteSessionCapability(
+ parse_json_string_result(method, envelope.result)?,
+ )),
RadrootsNostrConnectMethod::SignEvent => {
let event = parse_json_string_result::<Event>(method, envelope.result)?;
Ok(Self::SignedEvent(event))
@@ -592,3 +625,72 @@ fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> {
reason: error.to_string(),
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::method::RadrootsNostrConnectMethod;
+ use crate::permission::RadrootsNostrConnectPermission;
+
+ fn relay(value: &str) -> RelayUrl {
+ RelayUrl::parse(value).expect("relay url")
+ }
+
+ fn public_key() -> PublicKey {
+ PublicKey::from_hex("4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa")
+ .expect("public key")
+ }
+
+ #[test]
+ fn get_session_capability_request_round_trips_without_params() {
+ let request = RadrootsNostrConnectRequest::GetSessionCapability;
+ let message = RadrootsNostrConnectRequestMessage::new("req-cap", request.clone());
+ let encoded = serde_json::to_string(&message).expect("encode request");
+ let decoded: RadrootsNostrConnectRequestMessage =
+ serde_json::from_str(&encoded).expect("decode request");
+
+ assert_eq!(decoded.request, request);
+ assert_eq!(
+ decoded.request.method(),
+ RadrootsNostrConnectMethod::GetSessionCapability
+ );
+ }
+
+ #[test]
+ fn get_session_capability_response_round_trips() {
+ let capability = RadrootsNostrConnectRemoteSessionCapability {
+ user_public_key: public_key(),
+ relays: vec![
+ relay("wss://relay.example.com"),
+ relay("wss://relay2.example.com"),
+ ],
+ permissions: vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays),
+ ]
+ .into(),
+ };
+ let response = RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone());
+ let envelope = response
+ .into_envelope("resp-cap")
+ .expect("encode response envelope");
+ let decoded = RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::GetSessionCapability,
+ envelope,
+ )
+ .expect("decode capability response");
+
+ assert_eq!(
+ decoded,
+ RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone())
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone())
+ .into_pending_connection_poll_outcome(),
+ RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability)
+ );
+ }
+}
diff --git a/crates/nostr-connect/src/method.rs b/crates/nostr-connect/src/method.rs
@@ -7,6 +7,7 @@ use std::str::FromStr;
pub enum RadrootsNostrConnectMethod {
Connect,
GetPublicKey,
+ GetSessionCapability,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
@@ -22,6 +23,7 @@ impl RadrootsNostrConnectMethod {
match self {
Self::Connect => "connect",
Self::GetPublicKey => "get_public_key",
+ Self::GetSessionCapability => "get_session_capability",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
@@ -47,6 +49,7 @@ impl FromStr for RadrootsNostrConnectMethod {
match value {
"connect" => Ok(Self::Connect),
"get_public_key" => Ok(Self::GetPublicKey),
+ "get_session_capability" => Ok(Self::GetSessionCapability),
"sign_event" => Ok(Self::SignEvent),
"nip04_encrypt" => Ok(Self::Nip04Encrypt),
"nip04_decrypt" => Ok(Self::Nip04Decrypt),
diff --git a/crates/nostr-signer/src/evaluation.rs b/crates/nostr-signer/src/evaluation.rs
@@ -8,7 +8,7 @@ use nostr::{PublicKey, RelayUrl};
use radroots_identity::RadrootsIdentityPublic;
use radroots_nostr_connect::prelude::{
RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
- RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest,
};
#[derive(Debug, Clone)]
@@ -36,6 +36,7 @@ pub enum RadrootsNostrSignerRequestResponseHint {
None,
Pong,
UserPublicKey(PublicKey),
+ RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability),
RelayList(Vec<RelayUrl>),
}
@@ -103,6 +104,7 @@ pub(crate) fn required_permission_for_request(
match request {
RadrootsNostrConnectRequest::Connect { .. }
| RadrootsNostrConnectRequest::GetPublicKey
+ | RadrootsNostrConnectRequest::GetSessionCapability
| RadrootsNostrConnectRequest::Ping => None,
RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
Some(RadrootsNostrConnectPermission::with_parameter(
@@ -155,6 +157,15 @@ pub(crate) fn response_hint_for_request(
identity_public_key(&connection.user_identity)?,
))
}
+ RadrootsNostrConnectRequest::GetSessionCapability => Ok(
+ RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(
+ RadrootsNostrConnectRemoteSessionCapability {
+ user_public_key: identity_public_key(&connection.user_identity)?,
+ relays: connection.relays.clone(),
+ permissions: connection.effective_permissions(),
+ },
+ ),
+ ),
RadrootsNostrConnectRequest::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong),
RadrootsNostrConnectRequest::SwitchRelays => Ok(
RadrootsNostrSignerRequestResponseHint::RelayList(connection.relays.clone()),
@@ -273,6 +284,27 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn assert_response_hint_remote_session_capability(
+ hint: RadrootsNostrSignerRequestResponseHint,
+ expected_permissions: RadrootsNostrConnectPermissions,
+ ) {
+ match hint {
+ RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => {
+ let expected_public_key =
+ PublicKey::parse(fixture_diego_identity().public_key_hex.as_str())
+ .expect("user public key");
+ assert_eq!(
+ capability.user_public_key.to_hex(),
+ expected_public_key.to_hex()
+ );
+ assert_eq!(capability.relays, vec![primary_relay()]);
+ assert_eq!(capability.permissions, expected_permissions);
+ }
+ other => panic!("unexpected response hint: {other:?}"),
+ }
+ }
+
#[test]
fn connect_proposal_builds_connection_draft() {
let requested_permissions: RadrootsNostrConnectPermissions =
@@ -423,6 +455,7 @@ mod tests {
};
let ping = RadrootsNostrConnectRequest::Ping;
let get_public_key = RadrootsNostrConnectRequest::GetPublicKey;
+ let get_session_capability = RadrootsNostrConnectRequest::GetSessionCapability;
let switch_relays = RadrootsNostrConnectRequest::SwitchRelays;
let sign_event = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7));
let custom = RadrootsNostrConnectRequest::Custom {
@@ -433,6 +466,7 @@ mod tests {
assert!(required_permission_for_request(&connect).is_none());
assert!(required_permission_for_request(&ping).is_none());
assert!(required_permission_for_request(&get_public_key).is_none());
+ assert!(required_permission_for_request(&get_session_capability).is_none());
assert_eq!(
required_permission_for_request(&RadrootsNostrConnectRequest::Nip04Decrypt {
public_key,
@@ -495,6 +529,11 @@ mod tests {
assert_response_hint_user_public_key(
response_hint_for_request(&connection, &get_public_key).expect("pubkey hint"),
);
+ assert_response_hint_remote_session_capability(
+ response_hint_for_request(&connection, &get_session_capability)
+ .expect("capability hint"),
+ connection.effective_permissions(),
+ );
assert_eq!(
response_hint_for_request(&connection, &switch_relays).expect("relay hint"),
RadrootsNostrSignerRequestResponseHint::RelayList(vec![primary_relay()])