myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit f7ed72face51e5be82bf2c49c61a41a95472b861
parent f77c592056b6d85e403b8232fbc3d552bce20c05
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 22:26:51 +0000

transport: add base nip46 listener

- add encrypted kind 24133 request and response plumbing on top of the local transport bootstrap
- register inbound connect sessions and serve get_public_key, ping, and switch_relays through the rr-rs signer manager
- wire the async runtime into a live relay listener path while auto-granting switch_relays on new connections
- validate with cargo fmt, cargo check, cargo test, cargo check --locked, cargo test --locked, and cargo fmt --check

Diffstat:
MCargo.lock | 3+++
MCargo.toml | 5++++-
Msrc/app/runtime.rs | 40++++++++++++++++++++++++++++++++++------
Msrc/error.rs | 12++++++++++++
Msrc/transport.rs | 17+++++++++++++++++
Asrc/transport/nip46.rs | 552+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 622 insertions(+), 7 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -917,10 +917,13 @@ dependencies = [ name = "myc" version = "0.1.0" dependencies = [ + "nostr", "radroots-identity", "radroots-nostr", + "radroots-nostr-connect", "radroots-nostr-signer", "serde", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml @@ -14,12 +14,15 @@ resolver = "2" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] +nostr = { version = "0.44.2", features = ["nip04", "nip44"] } radroots-identity = { path = "../lib/crates/identity" } radroots-nostr = { path = "../lib/crates/nostr", features = ["client"] } +radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" thiserror = "2.0" -tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::config::MycConfig; use crate::error::MycError; -use crate::transport::{MycNostrTransport, MycTransportSnapshot}; +use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_signer::prelude::{RadrootsNostrFileSignerStore, RadrootsNostrSignerManager}; @@ -74,29 +74,33 @@ impl MycRuntime { } pub fn signer_identity(&self) -> &RadrootsIdentity { - &self.signer.signer_identity + self.signer.signer_identity() } pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { - self.signer.signer_identity.to_public() + self.signer.signer_public_identity() } pub fn user_identity(&self) -> &RadrootsIdentity { - &self.signer.user_identity + self.signer.user_identity() } pub fn user_public_identity(&self) -> RadrootsIdentityPublic { - self.signer.user_identity.to_public() + self.signer.user_public_identity() } pub fn signer_manager(&self) -> &RadrootsNostrSignerManager { - &self.signer.manager + self.signer.signer_manager() } pub fn transport(&self) -> Option<&MycNostrTransport> { self.transport.as_ref() } + pub(crate) fn signer_context(&self) -> MycSignerContext { + self.signer.clone() + } + pub fn snapshot(&self) -> MycStartupSnapshot { let signer_public = self.signer.signer_identity.to_public(); let user_public = self.signer.user_identity.to_public(); @@ -138,6 +142,10 @@ impl MycRuntime { transport_connect_timeout_secs = snapshot.transport.connect_timeout_secs, "myc runtime bootstrapped" ); + if let Some(transport) = self.transport.clone() { + let service = MycNip46Service::new(self.signer_context(), transport); + return service.run().await; + } Ok(()) } @@ -168,6 +176,26 @@ impl MycRuntimePaths { } impl MycSignerContext { + pub fn signer_identity(&self) -> &RadrootsIdentity { + &self.signer_identity + } + + pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { + self.signer_identity.to_public() + } + + pub fn user_identity(&self) -> &RadrootsIdentity { + &self.user_identity + } + + pub fn user_public_identity(&self) -> RadrootsIdentityPublic { + self.user_identity.to_public() + } + + pub fn signer_manager(&self) -> &RadrootsNostrSignerManager { + &self.manager + } + fn bootstrap(paths: &MycRuntimePaths) -> Result<Self, MycError> { let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?; diff --git a/src/error.rs b/src/error.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use radroots_identity::IdentityError; +use radroots_nostr::prelude::RadrootsNostrError; +use radroots_nostr_connect::prelude::RadrootsNostrConnectError; use radroots_nostr_signer::prelude::RadrootsNostrSignerError; use thiserror::Error; @@ -37,7 +39,17 @@ pub enum MycError { #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] + Nostr(#[from] RadrootsNostrError), + #[error(transparent)] + NostrConnect(#[from] RadrootsNostrConnectError), + #[error(transparent)] SignerState(#[from] RadrootsNostrSignerError), + #[error("NIP-46 decrypt failed: {0}")] + Nip46Decrypt(String), + #[error("NIP-46 encrypt failed: {0}")] + Nip46Encrypt(String), + #[error("NIP-46 listener notifications closed")] + Nip46ListenerClosed, #[error( "configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}" )] diff --git a/src/transport.rs b/src/transport.rs @@ -1,9 +1,15 @@ +pub mod nip46; + +use std::time::Duration; + use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrRelayUrl}; use crate::config::MycTransportConfig; use crate::error::MycError; +pub use nip46::{MycNip46Handler, MycNip46Service}; + #[derive(Clone)] pub struct MycNostrTransport { client: RadrootsNostrClient, @@ -46,6 +52,17 @@ impl MycNostrTransport { self.connect_timeout_secs } + pub async fn connect(&self) -> Result<(), MycError> { + for relay in &self.relays { + let _ = self.client.add_relay(relay.as_str()).await?; + } + self.client.connect().await; + self.client + .wait_for_connection(Duration::from_secs(self.connect_timeout_secs)) + .await; + Ok(()) + } + pub fn snapshot(&self) -> MycTransportSnapshot { MycTransportSnapshot { enabled: true, diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -0,0 +1,552 @@ +use nostr::nips::nip44; +use nostr::nips::nip44::Version; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, + RadrootsNostrPublicKey, RadrootsNostrRelayPoolNotification, RadrootsNostrRelayUrl, + RadrootsNostrTag, RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_kind, +}; +use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, +}; +use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint, + RadrootsNostrSignerSessionLookup, +}; +use tokio::sync::broadcast; + +use crate::app::MycSignerContext; +use crate::error::MycError; +use crate::transport::MycNostrTransport; + +#[derive(Clone)] +pub struct MycNip46Handler { + signer: MycSignerContext, + relays: Vec<RadrootsNostrRelayUrl>, +} + +pub struct MycNip46Service { + handler: MycNip46Handler, + transport: MycNostrTransport, +} + +impl MycNip46Handler { + pub fn new(signer: MycSignerContext, relays: Vec<RadrootsNostrRelayUrl>) -> Self { + Self { signer, relays } + } + + pub fn filter(&self) -> Result<RadrootsNostrFilter, MycError> { + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) + .since(RadrootsNostrTimestamp::now()); + radroots_nostr_filter_tag( + filter, + "p", + vec![self.signer.signer_public_identity().public_key_hex], + ) + .map_err(Into::into) + } + + pub fn parse_request_event( + &self, + event: &RadrootsNostrEvent, + ) -> Result<RadrootsNostrConnectRequestMessage, MycError> { + let decrypted = nip44::decrypt( + self.signer.signer_identity().keys().secret_key(), + &event.pubkey, + &event.content, + ) + .map_err(|err| MycError::Nip46Decrypt(err.to_string()))?; + serde_json::from_str(&decrypted) + .map_err(radroots_nostr_connect::prelude::RadrootsNostrConnectError::from) + .map_err(Into::into) + } + + pub fn build_response_event( + &self, + client_public_key: RadrootsNostrPublicKey, + request_id: impl Into<String>, + response: RadrootsNostrConnectResponse, + ) -> Result<RadrootsNostrEventBuilder, MycError> { + let envelope = response.into_envelope(request_id.into())?; + let payload = serde_json::to_string(&envelope) + .map_err(|err| MycError::Nip46Encrypt(err.to_string()))?; + let ciphertext = nip44::encrypt( + self.signer.signer_identity().keys().secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .map_err(|err| MycError::Nip46Encrypt(err.to_string()))?; + + Ok(RadrootsNostrEventBuilder::new( + radroots_nostr_kind(RADROOTS_NOSTR_CONNECT_RPC_KIND), + ciphertext, + ) + .tags(vec![RadrootsNostrTag::public_key(client_public_key)])) + } + + pub fn handle_request( + &self, + client_public_key: RadrootsNostrPublicKey, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrConnectResponse, MycError> { + match request_message.request.clone() { + RadrootsNostrConnectRequest::Connect { secret, .. } => { + self.handle_connect_request(client_public_key, request_message.request, secret) + } + RadrootsNostrConnectRequest::GetPublicKey + | RadrootsNostrConnectRequest::Ping + | RadrootsNostrConnectRequest::SwitchRelays => { + self.handle_base_request(client_public_key, request_message) + } + _ => Ok(RadrootsNostrConnectResponse::Error { + result: None, + error: format!( + "method `{}` is not implemented yet", + request_message.request.method() + ), + }), + } + } + + fn handle_connect_request( + &self, + client_public_key: RadrootsNostrPublicKey, + request: RadrootsNostrConnectRequest, + secret: Option<String>, + ) -> Result<RadrootsNostrConnectResponse, MycError> { + let evaluation = self + .signer + .signer_manager() + .evaluate_connect_request(client_public_key, request)?; + + match evaluation { + RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => { + Ok(connect_response(secret)) + } + RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => { + let draft = proposal + .into_connection_draft(self.signer.user_public_identity()) + .with_relays(self.relays.clone()); + let connection = self.signer.signer_manager().register_connection(draft)?; + let granted_permissions = + grant_permissions_for_new_connection(connection.requested_permissions.clone()); + let _ = self + .signer + .signer_manager() + .set_granted_permissions(&connection.connection_id, granted_permissions)?; + Ok(connect_response(secret)) + } + } + } + + fn handle_base_request( + &self, + client_public_key: RadrootsNostrPublicKey, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrConnectResponse, MycError> { + let connection = match self + .signer + .signer_manager() + .lookup_session(&client_public_key, None)? + { + RadrootsNostrSignerSessionLookup::Connection(connection) => connection, + RadrootsNostrSignerSessionLookup::None => { + return Ok(RadrootsNostrConnectResponse::Error { + result: None, + error: "unauthorized".to_owned(), + }); + } + RadrootsNostrSignerSessionLookup::Ambiguous(_) => { + return Ok(RadrootsNostrConnectResponse::Error { + result: None, + error: "ambiguous client sessions".to_owned(), + }); + } + }; + + let evaluation = self + .signer + .signer_manager() + .evaluate_request(&connection.connection_id, request_message)?; + + match evaluation.action { + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }) + } + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok( + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + ), + RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { + response_from_hint(&evaluation.connection, response_hint) + } + } + } +} + +impl MycNip46Service { + pub fn new(signer: MycSignerContext, transport: MycNostrTransport) -> Self { + let handler = MycNip46Handler::new(signer, transport.relays().to_vec()); + Self { handler, transport } + } + + pub async fn run(&self) -> Result<(), MycError> { + self.transport.connect().await?; + + let filter = self.handler.filter()?; + let mut notifications = self.transport.client().notifications(); + let subscription = self.transport.client().subscribe(filter, None).await?; + tracing::info!( + subscription_id = %subscription.val, + relay_count = self.transport.relays().len(), + "myc NIP-46 listener subscribed" + ); + + loop { + let notification = match notifications.recv().await { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(MycError::Nip46ListenerClosed); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = *event; + if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + continue; + } + + let request_message = match self.handler.parse_request_event(&event) { + Ok(message) => message, + Err(error) => { + tracing::warn!(error = %error, "discarding invalid NIP-46 request event"); + continue; + } + }; + + let request_id = request_message.id.clone(); + let response = match self.handler.handle_request(event.pubkey, request_message) { + Ok(response) => response, + Err(error) => { + tracing::warn!(error = %error, "failed to handle NIP-46 request"); + RadrootsNostrConnectResponse::Error { + result: None, + error: error.to_string(), + } + } + }; + + let response_event = + self.handler + .build_response_event(event.pubkey, request_id, response)?; + if let Err(error) = self + .transport + .client() + .send_event_builder(response_event) + .await + { + tracing::warn!(error = %error, "failed to publish NIP-46 response"); + } + } + } +} + +fn connect_response(secret: Option<String>) -> RadrootsNostrConnectResponse { + match secret { + Some(secret) => RadrootsNostrConnectResponse::ConnectSecretEcho(secret), + None => RadrootsNostrConnectResponse::ConnectAcknowledged, + } +} + +fn grant_permissions_for_new_connection( + requested_permissions: RadrootsNostrConnectPermissions, +) -> RadrootsNostrConnectPermissions { + let mut granted = requested_permissions.into_vec(); + let switch_relays = + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays); + if !granted.contains(&switch_relays) { + granted.push(switch_relays); + } + granted.sort(); + granted.dedup(); + granted.into() +} + +fn response_from_hint( + connection: &RadrootsNostrSignerConnectionRecord, + hint: RadrootsNostrSignerRequestResponseHint, +) -> Result<RadrootsNostrConnectResponse, MycError> { + Ok(match hint { + RadrootsNostrSignerRequestResponseHint::Pong => RadrootsNostrConnectResponse::Pong, + RadrootsNostrSignerRequestResponseHint::UserPublicKey(public_key) => { + RadrootsNostrConnectResponse::UserPublicKey(public_key) + } + RadrootsNostrSignerRequestResponseHint::RelayList(relays) => { + if relays == connection.relays { + RadrootsNostrConnectResponse::RelayList(relays) + } else { + RadrootsNostrConnectResponse::RelayList(connection.relays.clone()) + } + } + RadrootsNostrSignerRequestResponseHint::None => RadrootsNostrConnectResponse::Error { + result: None, + error: "request evaluation did not provide a response hint".to_owned(), + }, + }) +} + +#[cfg(test)] +mod tests { + use nostr::nips::nip44; + use nostr::nips::nip44::Version; + use nostr::{EventBuilder, Keys, PublicKey, SecretKey}; + use radroots_nostr::prelude::{RadrootsNostrTag, radroots_nostr_kind}; + use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod, + RadrootsNostrConnectPermission, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, + }; + + use crate::app::MycRuntime; + use crate::config::MycConfig; + + use super::MycNip46Handler; + + fn write_identity(path: &std::path::Path, secret_key: &str) { + radroots_identity::RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn runtime() -> MycRuntime { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.transport.enabled = true; + config.transport.connect_timeout_secs = 15; + config.transport.relays = vec!["wss://relay.example.com".to_owned()]; + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + MycRuntime::bootstrap(config).expect("runtime") + } + + fn handler(runtime: &MycRuntime) -> MycNip46Handler { + MycNip46Handler::new( + runtime.signer_context(), + runtime.transport().expect("transport").relays().to_vec(), + ) + } + + fn client_keys() -> Keys { + let secret = + SecretKey::from_hex("3333333333333333333333333333333333333333333333333333333333333333") + .expect("secret"); + Keys::new(secret) + } + + fn request_event( + handler: &MycNip46Handler, + request: RadrootsNostrConnectRequestMessage, + ) -> nostr::Event { + let client_keys = client_keys(); + let payload = serde_json::to_string(&request).expect("serialize request"); + let ciphertext = nip44::encrypt( + client_keys.secret_key(), + &PublicKey::parse( + handler + .signer + .signer_public_identity() + .public_key_hex + .as_str(), + ) + .expect("signer pubkey"), + payload, + Version::V2, + ) + .expect("encrypt"); + EventBuilder::new( + radroots_nostr_kind(RADROOTS_NOSTR_CONNECT_RPC_KIND), + ciphertext, + ) + .tags(vec![RadrootsNostrTag::public_key( + handler.signer.signer_identity().public_key(), + )]) + .sign_with_keys(&client_keys) + .expect("sign request") + } + + #[test] + fn parse_and_build_nip46_envelopes_roundtrip() { + let runtime = runtime(); + let handler = handler(&runtime); + let request = + RadrootsNostrConnectRequestMessage::new("req-1", RadrootsNostrConnectRequest::Ping); + let event = request_event(&handler, request.clone()); + + let parsed = handler.parse_request_event(&event).expect("parse request"); + assert_eq!(parsed, request); + + let response_builder = handler + .build_response_event(event.pubkey, "req-1", RadrootsNostrConnectResponse::Pong) + .expect("response builder"); + let response_event = response_builder + .sign_with_keys(runtime.signer_identity().keys()) + .expect("sign response"); + let decrypted = nip44::decrypt( + client_keys().secret_key(), + &runtime.signer_identity().public_key(), + &response_event.content, + ) + .expect("decrypt response"); + let envelope: RadrootsNostrConnectResponseEnvelope = + serde_json::from_str(&decrypted).expect("parse envelope"); + let parsed = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Ping.method(), + envelope, + ) + .expect("parse response"); + assert_eq!(parsed, RadrootsNostrConnectResponse::Pong); + } + + #[test] + fn connect_registers_client_and_echoes_secret() { + let runtime = runtime(); + let handler = handler(&runtime); + let response = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some("s3cr3t".to_owned()), + requested_permissions: Default::default(), + }, + ), + ) + .expect("connect response"); + + assert_eq!( + response, + RadrootsNostrConnectResponse::ConnectSecretEcho("s3cr3t".to_owned()) + ); + let connections = runtime + .signer_manager() + .list_connections() + .expect("connections"); + assert_eq!(connections.len(), 1); + assert_eq!( + connections[0].user_identity.id.to_string(), + runtime.user_public_identity().id.to_string() + ); + assert_eq!(connections[0].relays.len(), 1); + } + + #[test] + fn base_methods_return_spec_results_after_connect() { + let runtime = runtime(); + let handler = handler(&runtime); + handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("connect"); + + let public_key = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-pubkey", + RadrootsNostrConnectRequest::GetPublicKey, + ), + ) + .expect("get public key"); + assert_eq!( + public_key, + RadrootsNostrConnectResponse::UserPublicKey(runtime.user_identity().public_key()) + ); + + let pong = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-ping", + RadrootsNostrConnectRequest::Ping, + ), + ) + .expect("ping"); + assert_eq!(pong, RadrootsNostrConnectResponse::Pong); + + let relays = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-switch", + RadrootsNostrConnectRequest::SwitchRelays, + ), + ) + .expect("switch relays"); + assert_eq!( + relays, + RadrootsNostrConnectResponse::RelayList( + runtime.transport().expect("transport").relays().to_vec() + ) + ); + } + + #[test] + fn new_connections_auto_grant_switch_relays() { + let runtime = runtime(); + let handler = handler(&runtime); + handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("connect"); + + let connection = runtime + .signer_manager() + .list_connections() + .expect("connections") + .into_iter() + .next() + .expect("connection"); + assert!(connection.granted_permissions().as_slice().contains( + &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays,) + )); + } +}