app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

protocol.rs (30683B)


      1 use crate::error::RadrootsAppRemoteSignerError;
      2 use crate::input::{RadrootsAppRemoteSignerTarget, radroots_app_remote_signer_preview};
      3 use crate::session::RadrootsAppRemoteSignerSessionRecord;
      4 use nostr::JsonUtil;
      5 use nostr::{EventBuilder, RelayUrl, UnsignedEvent};
      6 use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
      7 use radroots_nostr::prelude::{
      8     RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind,
      9     RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp, radroots_nostr_filter_tag,
     10 };
     11 use radroots_nostr_connect::prelude::{
     12     RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientProgress,
     13     RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget,
     14     RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture,
     15     RadrootsNostrConnectError, RadrootsNostrConnectMethod,
     16     RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermissions,
     17     RadrootsNostrConnectRequest, RadrootsNostrConnectResponse, execute_request_with_transport,
     18 };
     19 use std::sync::atomic::{AtomicU64, Ordering};
     20 use std::time::Duration;
     21 use tokio::sync::broadcast;
     22 use tokio::time::timeout;
     23 
     24 const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
     25 const GET_SESSION_CAPABILITY_TIMEOUT: Duration = Duration::from_secs(60);
     26 const SWITCH_RELAYS_TIMEOUT: Duration = Duration::from_secs(30);
     27 const SIGN_EVENT_TIMEOUT: Duration = Duration::from_secs(60);
     28 static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
     29 
     30 #[derive(Debug, Clone)]
     31 pub struct RadrootsAppRemoteSignerPendingSession {
     32     pub record: RadrootsAppRemoteSignerSessionRecord,
     33     pub client_secret_key_hex: String,
     34 }
     35 
     36 #[derive(Debug, Clone)]
     37 pub struct RadrootsAppRemoteSignerApprovedSession {
     38     pub user_identity: RadrootsIdentityPublic,
     39     pub relays: Vec<String>,
     40     pub approved_permissions: RadrootsNostrConnectPermissions,
     41 }
     42 
     43 #[derive(Debug, Clone, PartialEq, Eq)]
     44 pub struct RadrootsAppRemoteSignerSignedEvent {
     45     pub event_id_hex: String,
     46     pub event_json: String,
     47     pub relays: Vec<String>,
     48 }
     49 
     50 #[derive(Debug, Clone, PartialEq, Eq)]
     51 pub enum RadrootsAppRemoteSignerProgressUpdate {
     52     AuthChallenge { url: String },
     53 }
     54 
     55 #[derive(Debug, Clone)]
     56 pub enum RadrootsAppRemoteSignerPendingPollOutcome {
     57     PendingApproval,
     58     Approved(RadrootsAppRemoteSignerApprovedSession),
     59     TransportFailure { message: String },
     60     Rejected { message: String },
     61     FatalError { message: String },
     62 }
     63 
     64 pub(crate) struct RadrootsAppRemoteSignerPendingPoller {
     65     client: ConnectedRemoteSignerSessionClient,
     66 }
     67 
     68 struct ConnectedRemoteSignerSessionClient {
     69     client_identity: RadrootsIdentity,
     70     target: RadrootsAppRemoteSignerTarget,
     71     client_target: RadrootsNostrConnectClientTarget,
     72     transport: ConnectedRemoteSignerTransport,
     73 }
     74 
     75 struct ConnectedRemoteSignerTransport {
     76     client: RadrootsNostrClient,
     77     notifications: broadcast::Receiver<RadrootsNostrRelayPoolNotification>,
     78 }
     79 
     80 pub async fn radroots_app_remote_signer_connect_pending(
     81     input: &str,
     82 ) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> {
     83     let target = radroots_app_remote_signer_preview(input)?;
     84     connect_pending_session(target).await
     85 }
     86 
     87 pub async fn radroots_app_remote_signer_poll_pending_session(
     88     record: &RadrootsAppRemoteSignerSessionRecord,
     89     client_secret_key_hex: &str,
     90 ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> {
     91     radroots_app_remote_signer_poll_pending_session_with_progress(
     92         record,
     93         client_secret_key_hex,
     94         |_| {},
     95     )
     96     .await
     97 }
     98 
     99 pub async fn radroots_app_remote_signer_poll_pending_session_with_progress<F>(
    100     record: &RadrootsAppRemoteSignerSessionRecord,
    101     client_secret_key_hex: &str,
    102     mut progress: F,
    103 ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
    104 where
    105     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    106 {
    107     let mut poller =
    108         radroots_app_remote_signer_open_pending_poller(record, client_secret_key_hex).await?;
    109     radroots_app_remote_signer_poll_pending_poller_with_progress(&mut poller, &mut progress).await
    110 }
    111 
    112 pub(crate) async fn radroots_app_remote_signer_open_pending_poller(
    113     record: &RadrootsAppRemoteSignerSessionRecord,
    114     client_secret_key_hex: &str,
    115 ) -> Result<RadrootsAppRemoteSignerPendingPoller, RadrootsAppRemoteSignerError> {
    116     let client_identity = load_client_identity(client_secret_key_hex)?;
    117     let target = target_for_record(record);
    118     Ok(RadrootsAppRemoteSignerPendingPoller {
    119         client: ConnectedRemoteSignerSessionClient::connect(client_identity, target).await?,
    120     })
    121 }
    122 
    123 pub(crate) async fn radroots_app_remote_signer_poll_pending_poller_with_progress<F>(
    124     poller: &mut RadrootsAppRemoteSignerPendingPoller,
    125     progress: &mut F,
    126 ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
    127 where
    128     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    129 {
    130     poller.poll_with_progress(progress).await
    131 }
    132 
    133 pub async fn radroots_app_remote_signer_sign_kind1_note(
    134     record: &RadrootsAppRemoteSignerSessionRecord,
    135     client_secret_key_hex: &str,
    136     content: &str,
    137 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
    138     radroots_app_remote_signer_sign_kind1_note_with_progress(
    139         record,
    140         client_secret_key_hex,
    141         content,
    142         |_| {},
    143     )
    144     .await
    145 }
    146 
    147 pub async fn radroots_app_remote_signer_sign_kind1_note_with_progress<F>(
    148     record: &RadrootsAppRemoteSignerSessionRecord,
    149     client_secret_key_hex: &str,
    150     content: &str,
    151     mut progress: F,
    152 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
    153 where
    154     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    155 {
    156     sign_kind1_note(record, client_secret_key_hex, content, &mut progress).await
    157 }
    158 
    159 pub async fn radroots_app_remote_signer_sign_unsigned_event(
    160     record: &RadrootsAppRemoteSignerSessionRecord,
    161     client_secret_key_hex: &str,
    162     unsigned_event: UnsignedEvent,
    163 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
    164     radroots_app_remote_signer_sign_unsigned_event_with_progress(
    165         record,
    166         client_secret_key_hex,
    167         unsigned_event,
    168         |_| {},
    169     )
    170     .await
    171 }
    172 
    173 pub async fn radroots_app_remote_signer_sign_unsigned_event_with_progress<F>(
    174     record: &RadrootsAppRemoteSignerSessionRecord,
    175     client_secret_key_hex: &str,
    176     unsigned_event: UnsignedEvent,
    177     mut progress: F,
    178 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
    179 where
    180     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    181 {
    182     sign_unsigned_event(record, client_secret_key_hex, unsigned_event, &mut progress).await
    183 }
    184 
    185 async fn connect_pending_session(
    186     target: RadrootsAppRemoteSignerTarget,
    187 ) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> {
    188     let client_identity = RadrootsIdentity::generate();
    189     let connect_request = connect_request_for_target(&target);
    190     let response = execute_request(
    191         &client_identity,
    192         &target,
    193         RadrootsNostrConnectMethod::Connect,
    194         connect_request,
    195         CONNECT_TIMEOUT,
    196     )
    197     .await?;
    198 
    199     match response {
    200         RadrootsNostrConnectResponse::ConnectAcknowledged
    201         | RadrootsNostrConnectResponse::ConnectSecretEcho(_) => {
    202             Ok(RadrootsAppRemoteSignerPendingSession {
    203                 record: RadrootsAppRemoteSignerSessionRecord::pending(
    204                     client_identity.to_public(),
    205                     target.signer_identity,
    206                     target.relays,
    207                 ),
    208                 client_secret_key_hex: client_identity.secret_key_hex(),
    209             })
    210         }
    211         other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
    212             method: RadrootsNostrConnectMethod::Connect,
    213             response: format!("{other:?}"),
    214         }),
    215     }
    216 }
    217 
    218 fn connect_request_for_target(
    219     target: &RadrootsAppRemoteSignerTarget,
    220 ) -> RadrootsNostrConnectRequest {
    221     RadrootsNostrConnectRequest::Connect {
    222         remote_signer_public_key: parse_public_key_hex(
    223             target.signer_identity.public_key_hex.as_str(),
    224         )
    225         .expect("signer public key is derived from a validated identity"),
    226         secret: target.connect_secret.clone(),
    227         requested_permissions: target.requested_permissions.clone(),
    228     }
    229 }
    230 
    231 async fn sign_kind1_note<F>(
    232     record: &RadrootsAppRemoteSignerSessionRecord,
    233     client_secret_key_hex: &str,
    234     content: &str,
    235     progress: &mut F,
    236 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
    237 where
    238     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    239 {
    240     if !record.allows_sign_event_kind1() {
    241         return Err(RadrootsAppRemoteSignerError::ConnectFailed(
    242             "remote signer has not approved sign_event:kind:1".to_owned(),
    243         ));
    244     }
    245     let user_identity = record.user_identity.as_ref().ok_or_else(|| {
    246         RadrootsAppRemoteSignerError::ConnectFailed(
    247             "remote signer session is missing the approved user identity".to_owned(),
    248         )
    249     })?;
    250     let unsigned_event = EventBuilder::text_note(content.trim())
    251         .build(parse_public_key_hex(user_identity.public_key_hex.as_str())?);
    252     sign_unsigned_event(record, client_secret_key_hex, unsigned_event, progress).await
    253 }
    254 
    255 async fn sign_unsigned_event<F>(
    256     record: &RadrootsAppRemoteSignerSessionRecord,
    257     client_secret_key_hex: &str,
    258     unsigned_event: UnsignedEvent,
    259     progress: &mut F,
    260 ) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
    261 where
    262     F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    263 {
    264     let client_identity = load_client_identity(client_secret_key_hex)?;
    265     let target = target_for_record(record);
    266     let mut client = ConnectedRemoteSignerSessionClient::connect(client_identity, target).await?;
    267     let relays = client.sync_relays_if_allowed(record, progress).await?;
    268     let response = client
    269         .execute_request_with_progress(
    270             RadrootsNostrConnectMethod::SignEvent,
    271             RadrootsNostrConnectRequest::SignEvent(unsigned_event),
    272             SIGN_EVENT_TIMEOUT,
    273             progress,
    274         )
    275         .await?;
    276 
    277     match response {
    278         RadrootsNostrConnectResponse::SignedEvent(event) => {
    279             Ok(RadrootsAppRemoteSignerSignedEvent {
    280                 event_id_hex: event.id.to_hex(),
    281                 event_json: event.as_json(),
    282                 relays,
    283             })
    284         }
    285         RadrootsNostrConnectResponse::Error { error, .. } => {
    286             Err(RadrootsAppRemoteSignerError::ConnectFailed(error))
    287         }
    288         other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
    289             method: RadrootsNostrConnectMethod::SignEvent,
    290             response: format!("{other:?}"),
    291         }),
    292     }
    293 }
    294 
    295 async fn execute_request(
    296     client_identity: &RadrootsIdentity,
    297     target: &RadrootsAppRemoteSignerTarget,
    298     method: RadrootsNostrConnectMethod,
    299     request: RadrootsNostrConnectRequest,
    300     request_timeout: Duration,
    301 ) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> {
    302     let mut client =
    303         ConnectedRemoteSignerSessionClient::connect(client_identity.clone(), target.clone())
    304             .await?;
    305     client
    306         .execute_request_with_progress(method, request, request_timeout, &mut |_| {})
    307         .await
    308 }
    309 
    310 impl RadrootsAppRemoteSignerPendingPoller {
    311     async fn poll_with_progress<F>(
    312         &mut self,
    313         progress: &mut F,
    314     ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
    315     where
    316         F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    317     {
    318         match self
    319             .client
    320             .execute_request_with_progress(
    321                 RadrootsNostrConnectMethod::GetSessionCapability,
    322                 RadrootsNostrConnectRequest::GetSessionCapability,
    323                 GET_SESSION_CAPABILITY_TIMEOUT,
    324                 progress,
    325             )
    326             .await
    327         {
    328             Ok(response) => Ok(classify_pending_poll_response(response)),
    329             Err(error) => Ok(classify_pending_poll_error(error)),
    330         }
    331     }
    332 }
    333 
    334 impl ConnectedRemoteSignerSessionClient {
    335     async fn connect(
    336         client_identity: RadrootsIdentity,
    337         target: RadrootsAppRemoteSignerTarget,
    338     ) -> Result<Self, RadrootsAppRemoteSignerError> {
    339         let client_target = client_target_for_app_target(&target)?;
    340         let client = RadrootsNostrClient::from_identity(&client_identity);
    341         for relay in &target.relays {
    342             client
    343                 .add_relay(relay)
    344                 .await
    345                 .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
    346         }
    347         client.connect().await;
    348         let filter = radroots_nostr_filter_tag(
    349             RadrootsNostrFilter::new()
    350                 .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND))
    351                 .since(RadrootsNostrTimestamp::now()),
    352             "p",
    353             vec![client_identity.public_key_hex()],
    354         )
    355         .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
    356         let notifications = client.notifications();
    357         client
    358             .subscribe(filter, None)
    359             .await
    360             .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
    361 
    362         Ok(Self {
    363             client_identity,
    364             target,
    365             client_target,
    366             transport: ConnectedRemoteSignerTransport {
    367                 client,
    368                 notifications,
    369             },
    370         })
    371     }
    372 
    373     async fn sync_relays_if_allowed<F>(
    374         &mut self,
    375         record: &RadrootsAppRemoteSignerSessionRecord,
    376         progress: &mut F,
    377     ) -> Result<Vec<String>, RadrootsAppRemoteSignerError>
    378     where
    379         F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    380     {
    381         if !record.allows_switch_relays() {
    382             return Ok(self.target.relays.clone());
    383         }
    384 
    385         match self
    386             .execute_request_with_progress(
    387                 RadrootsNostrConnectMethod::SwitchRelays,
    388                 RadrootsNostrConnectRequest::SwitchRelays,
    389                 SWITCH_RELAYS_TIMEOUT,
    390                 progress,
    391             )
    392             .await?
    393         {
    394             RadrootsNostrConnectResponse::RelayList(relays) => {
    395                 let relays: Vec<String> = relays.iter().map(ToString::to_string).collect();
    396                 self.client_target.relays = relays
    397                     .iter()
    398                     .map(|relay| parse_relay_url(relay))
    399                     .collect::<Result<Vec<_>, _>>()?;
    400                 self.target.relays = relays.clone();
    401                 Ok(relays)
    402             }
    403             RadrootsNostrConnectResponse::RelayListUnchanged => Ok(self.target.relays.clone()),
    404             RadrootsNostrConnectResponse::Error { error, .. } => {
    405                 Err(RadrootsAppRemoteSignerError::ConnectFailed(format!(
    406                     "remote signer rejected relay update: {error}"
    407                 )))
    408             }
    409             other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
    410                 method: RadrootsNostrConnectMethod::SwitchRelays,
    411                 response: format!("{other:?}"),
    412             }),
    413         }
    414     }
    415 
    416     async fn execute_request_with_progress<F>(
    417         &mut self,
    418         method: RadrootsNostrConnectMethod,
    419         request: RadrootsNostrConnectRequest,
    420         request_timeout: Duration,
    421         progress: &mut F,
    422     ) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError>
    423     where
    424         F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
    425     {
    426         let request_id = next_request_id(method.to_string().as_str());
    427         let response_method = method.clone();
    428         let client_keys = self.client_identity.keys().clone();
    429         let client_target = self.client_target.clone();
    430         let request = RadrootsNostrConnectClientRequest::new(request_id, request);
    431         let response = timeout(
    432             request_timeout,
    433             execute_request_with_transport(
    434                 &client_keys,
    435                 &client_target,
    436                 request,
    437                 &mut self.transport,
    438                 |event| {
    439                     match event {
    440                         RadrootsNostrConnectClientProgress::AuthChallenge { url } => {
    441                             progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url });
    442                         }
    443                     }
    444                     Ok(())
    445                 },
    446             ),
    447         )
    448         .await
    449         .map_err(|_| RadrootsAppRemoteSignerError::RequestTimedOut {
    450             method: response_method.clone(),
    451         })?;
    452         response.map_err(|error| app_error_from_nostr_connect_error(&response_method, error))
    453     }
    454 }
    455 
    456 impl RadrootsNostrConnectClientTransport for ConnectedRemoteSignerTransport {
    457     fn publish_request_event<'a>(
    458         &'a mut self,
    459         event: RadrootsNostrEvent,
    460     ) -> RadrootsNostrConnectClientTransportFuture<'a, ()> {
    461         Box::pin(async move {
    462             self.client
    463                 .send_event(&event)
    464                 .await
    465                 .map(|_| ())
    466                 .map_err(|error| RadrootsNostrConnectError::Transport {
    467                     reason: error.to_string(),
    468                 })
    469         })
    470     }
    471 
    472     fn next_response_event<'a>(
    473         &'a mut self,
    474     ) -> RadrootsNostrConnectClientTransportFuture<'a, RadrootsNostrEvent> {
    475         Box::pin(async move {
    476             loop {
    477                 let notification = match self.notifications.recv().await {
    478                     Ok(notification) => notification,
    479                     Err(broadcast::error::RecvError::Lagged(_)) => continue,
    480                     Err(broadcast::error::RecvError::Closed) => {
    481                         return Err(RadrootsNostrConnectError::Transport {
    482                             reason: "remote signer notification stream closed".to_owned(),
    483                         });
    484                     }
    485                 };
    486                 let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else {
    487                     continue;
    488                 };
    489                 let event = *event;
    490                 if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
    491                     continue;
    492                 }
    493                 return Ok(event);
    494             }
    495         })
    496     }
    497 }
    498 
    499 fn client_target_for_app_target(
    500     target: &RadrootsAppRemoteSignerTarget,
    501 ) -> Result<RadrootsNostrConnectClientTarget, RadrootsAppRemoteSignerError> {
    502     Ok(RadrootsNostrConnectClientTarget::new(
    503         parse_public_key_hex(target.signer_identity.public_key_hex.as_str())?,
    504         target
    505             .relays
    506             .iter()
    507             .map(|relay| parse_relay_url(relay))
    508             .collect::<Result<Vec<_>, _>>()?,
    509     ))
    510 }
    511 
    512 fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsAppRemoteSignerError> {
    513     RelayUrl::parse(value).map_err(|error| {
    514         RadrootsAppRemoteSignerError::ConnectFailed(format!(
    515             "invalid remote signer relay `{value}`: {error}"
    516         ))
    517     })
    518 }
    519 
    520 fn app_error_from_nostr_connect_error(
    521     method: &RadrootsNostrConnectMethod,
    522     error: RadrootsNostrConnectError,
    523 ) -> RadrootsAppRemoteSignerError {
    524     match error {
    525         RadrootsNostrConnectError::RequestTimedOut => {
    526             RadrootsAppRemoteSignerError::RequestTimedOut {
    527                 method: method.clone(),
    528             }
    529         }
    530         RadrootsNostrConnectError::Transport { reason }
    531         | RadrootsNostrConnectError::Encrypt { reason }
    532         | RadrootsNostrConnectError::Sign { reason } => {
    533             RadrootsAppRemoteSignerError::ConnectFailed(reason)
    534         }
    535         RadrootsNostrConnectError::Decrypt { reason }
    536         | RadrootsNostrConnectError::Json(reason)
    537         | RadrootsNostrConnectError::InvalidResponsePayload { reason, .. } => {
    538             RadrootsAppRemoteSignerError::UnexpectedResponse {
    539                 method: method.clone(),
    540                 response: reason,
    541             }
    542         }
    543         other => RadrootsAppRemoteSignerError::UnexpectedResponse {
    544             method: method.clone(),
    545             response: other.to_string(),
    546         },
    547     }
    548 }
    549 
    550 fn classify_pending_poll_response(
    551     response: RadrootsNostrConnectResponse,
    552 ) -> RadrootsAppRemoteSignerPendingPollOutcome {
    553     match response.into_pending_connection_poll_outcome() {
    554         RadrootsNostrConnectPendingConnectionPollOutcome::Approved(public_key) => {
    555             RadrootsAppRemoteSignerPendingPollOutcome::Approved(
    556                 RadrootsAppRemoteSignerApprovedSession {
    557                     user_identity: RadrootsIdentityPublic::new(public_key),
    558                     relays: Vec::new(),
    559                     approved_permissions: RadrootsNostrConnectPermissions::default(),
    560                 },
    561             )
    562         }
    563         RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability) => {
    564             RadrootsAppRemoteSignerPendingPollOutcome::Approved(
    565                 RadrootsAppRemoteSignerApprovedSession {
    566                     user_identity: RadrootsIdentityPublic::new(capability.user_public_key),
    567                     relays: capability
    568                         .relays
    569                         .into_iter()
    570                         .map(|relay| relay.to_string())
    571                         .collect(),
    572                     approved_permissions: capability.permissions,
    573                 },
    574             )
    575         }
    576         RadrootsNostrConnectPendingConnectionPollOutcome::PendingApproval => {
    577             RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval
    578         }
    579         RadrootsNostrConnectPendingConnectionPollOutcome::Rejected { message } => {
    580             RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }
    581         }
    582         RadrootsNostrConnectPendingConnectionPollOutcome::AuthChallenge { url } => {
    583             RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
    584                 message: format!("unexpected remote signer authorization challenge: {url}"),
    585             }
    586         }
    587         RadrootsNostrConnectPendingConnectionPollOutcome::UnexpectedResponse { response } => {
    588             RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
    589                 message: format!("unexpected remote signer response: {response}"),
    590             }
    591         }
    592     }
    593 }
    594 
    595 fn classify_pending_poll_error(
    596     error: RadrootsAppRemoteSignerError,
    597 ) -> RadrootsAppRemoteSignerPendingPollOutcome {
    598     match error {
    599         RadrootsAppRemoteSignerError::RequestTimedOut { .. } => {
    600             RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure {
    601                 message: "remote signer did not respond yet".to_owned(),
    602             }
    603         }
    604         RadrootsAppRemoteSignerError::ConnectFailed(message) => {
    605             RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }
    606         }
    607         RadrootsAppRemoteSignerError::UnexpectedResponse { .. } => {
    608             RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
    609                 message: error.to_string(),
    610             }
    611         }
    612         other => RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
    613             message: other.to_string(),
    614         },
    615     }
    616 }
    617 
    618 fn next_request_id(prefix: &str) -> String {
    619     let tick = REQUEST_COUNTER.fetch_add(1, Ordering::AcqRel);
    620     format!("{prefix}-{tick}")
    621 }
    622 
    623 fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsAppRemoteSignerError> {
    624     nostr::PublicKey::parse(value)
    625         .or_else(|_| nostr::PublicKey::from_hex(value))
    626         .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
    627 }
    628 
    629 fn load_client_identity(
    630     client_secret_key_hex: &str,
    631 ) -> Result<RadrootsIdentity, RadrootsAppRemoteSignerError> {
    632     RadrootsIdentity::from_secret_key_str(client_secret_key_hex)
    633         .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
    634 }
    635 
    636 fn target_for_record(
    637     record: &RadrootsAppRemoteSignerSessionRecord,
    638 ) -> RadrootsAppRemoteSignerTarget {
    639     RadrootsAppRemoteSignerTarget {
    640         source: crate::RadrootsAppRemoteSignerSource::BunkerUri,
    641         signer_identity: record.signer_identity.clone(),
    642         relays: record.relays.clone(),
    643         connect_secret: None,
    644         requested_permissions: if record.approved_permissions.is_empty() {
    645             crate::radroots_app_remote_signer_requested_permissions()
    646         } else {
    647             record.approved_permissions.clone()
    648         },
    649     }
    650 }
    651 
    652 #[cfg(test)]
    653 mod tests {
    654     use super::*;
    655     use crate::radroots_app_remote_signer_preview;
    656     use radroots_identity::RadrootsIdentity;
    657     use radroots_nostr_connect::prelude::{
    658         RadrootsNostrConnectPermission, RadrootsNostrConnectRemoteSessionCapability,
    659     };
    660 
    661     const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com";
    662     const SIGNER_SECRET_KEY_HEX: &str =
    663         "1111111111111111111111111111111111111111111111111111111111111111";
    664     const CLIENT_SECRET_KEY_HEX: &str =
    665         "2222222222222222222222222222222222222222222222222222222222222222";
    666 
    667     fn fixture_identity(secret_key_hex: &str) -> RadrootsIdentity {
    668         RadrootsIdentity::from_secret_key_str(secret_key_hex).expect("identity")
    669     }
    670 
    671     fn fixture_public_key() -> nostr::PublicKey {
    672         fixture_identity(SIGNER_SECRET_KEY_HEX).public_key()
    673     }
    674 
    675     fn fixture_discovery_url() -> String {
    676         format!(
    677             "http://localhost/connect?uri={}",
    678             url::form_urlencoded::byte_serialize(
    679                 format!(
    680                     "bunker://{}?relay={RELAY_PRIMARY_WSS}",
    681                     fixture_identity(SIGNER_SECRET_KEY_HEX).public_key_hex()
    682                 )
    683                 .as_bytes()
    684             )
    685             .collect::<String>()
    686         )
    687     }
    688 
    689     #[test]
    690     fn pending_connection_response_is_classified_as_pending_approval() {
    691         let outcome =
    692             classify_pending_poll_response(RadrootsNostrConnectResponse::PendingConnection);
    693 
    694         assert!(matches!(
    695             outcome,
    696             RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval
    697         ));
    698     }
    699 
    700     #[test]
    701     fn signer_error_response_is_classified_as_rejected() {
    702         let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::Error {
    703             result: None,
    704             error: "unauthorized".to_owned(),
    705         });
    706 
    707         assert!(matches!(
    708             outcome,
    709             RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }
    710                 if message == "unauthorized"
    711         ));
    712     }
    713 
    714     #[test]
    715     fn session_capability_success_is_classified_as_approved() {
    716         let outcome =
    717             classify_pending_poll_response(RadrootsNostrConnectResponse::RemoteSessionCapability(
    718                 RadrootsNostrConnectRemoteSessionCapability {
    719                     user_public_key: fixture_public_key(),
    720                     relays: vec![nostr::RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")],
    721                     permissions: vec![
    722                         RadrootsNostrConnectPermission::with_parameter(
    723                             RadrootsNostrConnectMethod::SignEvent,
    724                             "kind:1",
    725                         ),
    726                         RadrootsNostrConnectPermission::new(
    727                             RadrootsNostrConnectMethod::SwitchRelays,
    728                         ),
    729                     ]
    730                     .into(),
    731                 },
    732             ));
    733 
    734         assert!(matches!(
    735             outcome,
    736             RadrootsAppRemoteSignerPendingPollOutcome::Approved(
    737                 RadrootsAppRemoteSignerApprovedSession { user_identity, approved_permissions, .. }
    738             ) if user_identity.public_key_hex == fixture_public_key().to_hex()
    739                 && approved_permissions.to_string() == "sign_event:kind:1,switch_relays"
    740         ));
    741     }
    742 
    743     #[test]
    744     fn timeout_error_is_classified_as_transport_failure() {
    745         let outcome = classify_pending_poll_error(RadrootsAppRemoteSignerError::RequestTimedOut {
    746             method: RadrootsNostrConnectMethod::GetSessionCapability,
    747         });
    748 
    749         assert!(matches!(
    750             outcome,
    751             RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }
    752                 if message == "remote signer did not respond yet"
    753         ));
    754     }
    755 
    756     #[test]
    757     fn unexpected_response_error_is_fatal() {
    758         let outcome =
    759             classify_pending_poll_error(RadrootsAppRemoteSignerError::UnexpectedResponse {
    760                 method: RadrootsNostrConnectMethod::GetSessionCapability,
    761                 response: "failed to decode signer response envelope: bad".to_owned(),
    762             });
    763 
    764         assert!(matches!(
    765             outcome,
    766             RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }
    767                 if message.contains("unexpected `get_session_capability` response")
    768         ));
    769     }
    770 
    771     #[test]
    772     fn connect_request_uses_explicit_requested_permissions() {
    773         let target =
    774             radroots_app_remote_signer_preview(fixture_discovery_url().as_str()).expect("preview");
    775 
    776         let request = connect_request_for_target(&target);
    777 
    778         match request {
    779             RadrootsNostrConnectRequest::Connect {
    780                 requested_permissions,
    781                 ..
    782             } => assert_eq!(
    783                 requested_permissions.to_string(),
    784                 "sign_event:kind:1,switch_relays"
    785             ),
    786             other => panic!("unexpected request: {other:?}"),
    787         }
    788     }
    789 
    790     #[test]
    791     fn sign_kind1_note_output_carries_signed_relay_state() {
    792         let signed_event = RadrootsAppRemoteSignerSignedEvent {
    793             event_id_hex: "deadbeef".to_owned(),
    794             event_json: "{\"id\":\"deadbeef\"}".to_owned(),
    795             relays: vec!["ws://localhost:8080".to_owned()],
    796         };
    797 
    798         assert_eq!(signed_event.event_id_hex, "deadbeef");
    799         assert_eq!(signed_event.relays, vec!["ws://localhost:8080".to_owned()]);
    800     }
    801 
    802     #[test]
    803     fn target_for_record_uses_approved_permissions_when_available() {
    804         let client_identity = fixture_identity(CLIENT_SECRET_KEY_HEX).to_public();
    805         let signer_identity = fixture_identity(SIGNER_SECRET_KEY_HEX).to_public();
    806         let mut record = RadrootsAppRemoteSignerSessionRecord::pending(
    807             client_identity,
    808             signer_identity,
    809             vec![RELAY_PRIMARY_WSS.to_owned()],
    810         );
    811         record.approved_permissions = vec![RadrootsNostrConnectPermission::new(
    812             RadrootsNostrConnectMethod::SwitchRelays,
    813         )]
    814         .into();
    815 
    816         let target = target_for_record(&record);
    817 
    818         assert_eq!(target.requested_permissions.to_string(), "switch_relays");
    819     }
    820 }