radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

session.rs (24835B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::collections::{HashMap, HashSet};
      4 use std::sync::Arc;
      5 use std::time::{Duration, Instant};
      6 
      7 use serde::{Deserialize, Serialize};
      8 use tokio::sync::Mutex;
      9 
     10 use nostr::nips::nip46::NostrConnectRequest;
     11 use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrKeys, RadrootsNostrPublicKey};
     12 
     13 #[derive(Clone)]
     14 pub struct Nip46SessionStore {
     15     inner: Arc<Mutex<HashMap<String, Nip46Session>>>,
     16     used_secrets: Arc<Mutex<HashSet<String>>>,
     17 }
     18 
     19 #[derive(Clone)]
     20 pub struct PendingNostrRequest {
     21     pub request_id: String,
     22     pub client_pubkey: RadrootsNostrPublicKey,
     23     pub request: NostrConnectRequest,
     24 }
     25 
     26 pub struct Nip46AuthorizeOutcome {
     27     pub pending: Option<PendingNostrRequest>,
     28 }
     29 
     30 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
     31 #[serde(rename_all = "snake_case")]
     32 pub enum Nip46SessionRole {
     33     InboundLocalSigner,
     34     OutboundRemoteSigner,
     35 }
     36 
     37 #[derive(Clone, Debug, Serialize)]
     38 pub struct Nip46SessionView {
     39     pub session_id: String,
     40     pub role: Nip46SessionRole,
     41     pub client_pubkey: String,
     42     pub signer_pubkey: String,
     43     pub user_pubkey: Option<String>,
     44     pub relays: Vec<String>,
     45     pub permissions: Vec<String>,
     46     pub name: Option<String>,
     47     pub url: Option<String>,
     48     pub image: Option<String>,
     49     pub auth_required: bool,
     50     pub authorized: bool,
     51     pub auth_url: Option<String>,
     52     pub expires_in_secs: Option<u64>,
     53     #[serde(default, skip_serializing_if = "Option::is_none")]
     54     pub signer_authority: Option<Nip46SessionAuthority>,
     55 }
     56 
     57 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
     58 pub struct Nip46SessionAuthority {
     59     pub provider_runtime_id: String,
     60     pub account_identity_id: String,
     61     #[serde(default, skip_serializing_if = "Option::is_none")]
     62     pub provider_session_id: Option<String>,
     63 }
     64 
     65 #[derive(Clone)]
     66 pub struct Nip46Session {
     67     pub id: String,
     68     pub client: RadrootsNostrClient,
     69     pub client_keys: RadrootsNostrKeys,
     70     pub client_pubkey: RadrootsNostrPublicKey,
     71     pub remote_signer_pubkey: RadrootsNostrPublicKey,
     72     pub user_pubkey: Option<RadrootsNostrPublicKey>,
     73     pub relays: Vec<String>,
     74     pub perms: Vec<String>,
     75     pub name: Option<String>,
     76     pub url: Option<String>,
     77     pub image: Option<String>,
     78     pub expires_at: Option<Instant>,
     79     pub auth_required: bool,
     80     pub authorized: bool,
     81     pub auth_url: Option<String>,
     82     pub pending_request: Option<PendingNostrRequest>,
     83     pub signer_authority: Option<Nip46SessionAuthority>,
     84 }
     85 
     86 impl Nip46SessionStore {
     87     pub fn new() -> Self {
     88         Self {
     89             inner: Arc::new(Mutex::new(HashMap::new())),
     90             used_secrets: Arc::new(Mutex::new(HashSet::new())),
     91         }
     92     }
     93 
     94     pub async fn insert(&self, session: Nip46Session) {
     95         let mut sessions = self.inner.lock().await;
     96         sessions.insert(session.id.clone(), session);
     97     }
     98 
     99     pub async fn get(&self, session_id: &str) -> Option<Nip46Session> {
    100         let mut sessions = self.inner.lock().await;
    101         let expired = sessions
    102             .get(session_id)
    103             .map(|session| session.is_expired())
    104             .unwrap_or(false);
    105         if expired {
    106             sessions.remove(session_id);
    107             return None;
    108         }
    109         sessions.get(session_id).cloned()
    110     }
    111 
    112     pub async fn remove(&self, session_id: &str) -> bool {
    113         let mut sessions = self.inner.lock().await;
    114         sessions.remove(session_id).is_some()
    115     }
    116 
    117     pub async fn set_user_pubkey(&self, session_id: &str, pubkey: RadrootsNostrPublicKey) -> bool {
    118         let mut sessions = self.inner.lock().await;
    119         match sessions.get_mut(session_id) {
    120             Some(session) => {
    121                 if session.is_expired() {
    122                     sessions.remove(session_id);
    123                     return false;
    124                 }
    125                 session.user_pubkey = Some(pubkey);
    126                 true
    127             }
    128             None => false,
    129         }
    130     }
    131 
    132     pub async fn require_auth(&self, session_id: &str, auth_url: String) -> bool {
    133         let mut sessions = self.inner.lock().await;
    134         match sessions.get_mut(session_id) {
    135             Some(session) => {
    136                 if session.is_expired() {
    137                     sessions.remove(session_id);
    138                     return false;
    139                 }
    140                 session.auth_required = true;
    141                 session.authorized = false;
    142                 session.auth_url = Some(auth_url);
    143                 session.pending_request = None;
    144                 true
    145             }
    146             None => false,
    147         }
    148     }
    149 
    150     pub async fn authorize(&self, session_id: &str) -> Option<Nip46AuthorizeOutcome> {
    151         let mut sessions = self.inner.lock().await;
    152         match sessions.get_mut(session_id) {
    153             Some(session) => {
    154                 if session.is_expired() {
    155                     sessions.remove(session_id);
    156                     return None;
    157                 }
    158                 session.authorized = true;
    159                 Some(Nip46AuthorizeOutcome {
    160                     pending: session.pending_request.take(),
    161                 })
    162             }
    163             None => None,
    164         }
    165     }
    166 
    167     pub async fn set_pending_request(
    168         &self,
    169         session_id: &str,
    170         pending: PendingNostrRequest,
    171     ) -> bool {
    172         let mut sessions = self.inner.lock().await;
    173         match sessions.get_mut(session_id) {
    174             Some(session) => {
    175                 if session.is_expired() {
    176                     sessions.remove(session_id);
    177                     return false;
    178                 }
    179                 session.pending_request = Some(pending);
    180                 true
    181             }
    182             None => false,
    183         }
    184     }
    185 
    186     pub async fn list(&self) -> Vec<Nip46Session> {
    187         let mut sessions = self.inner.lock().await;
    188         sessions.retain(|_, session| !session.is_expired());
    189         let mut listed: Vec<Nip46Session> = sessions.values().cloned().collect();
    190         listed.sort_by(|left, right| left.id.cmp(&right.id));
    191         listed
    192     }
    193 
    194     pub async fn claim_secret(&self, secret: &str) -> bool {
    195         let mut secrets = self.used_secrets.lock().await;
    196         if secrets.contains(secret) {
    197             return false;
    198         }
    199         secrets.insert(secret.to_string());
    200         true
    201     }
    202 }
    203 
    204 impl Nip46Session {
    205     pub fn normalize_authority(
    206         authority: Option<Nip46SessionAuthority>,
    207     ) -> Result<Option<Nip46SessionAuthority>, String> {
    208         authority
    209             .map(|authority| authority.normalized())
    210             .transpose()
    211     }
    212 
    213     pub fn is_expired(&self) -> bool {
    214         self.expires_at
    215             .map(|expires_at| expires_at <= Instant::now())
    216             .unwrap_or(false)
    217     }
    218 
    219     pub fn role(&self) -> Nip46SessionRole {
    220         if self.client_keys.public_key() == self.remote_signer_pubkey {
    221             Nip46SessionRole::InboundLocalSigner
    222         } else {
    223             Nip46SessionRole::OutboundRemoteSigner
    224         }
    225     }
    226 
    227     pub fn public_view(&self) -> Nip46SessionView {
    228         Nip46SessionView {
    229             session_id: self.id.clone(),
    230             role: self.role(),
    231             client_pubkey: self.client_pubkey.to_hex(),
    232             signer_pubkey: self.remote_signer_pubkey.to_hex(),
    233             user_pubkey: self.user_pubkey.as_ref().map(|pubkey| pubkey.to_hex()),
    234             relays: self.relays.clone(),
    235             permissions: self.perms.clone(),
    236             name: self.name.clone(),
    237             url: self.url.clone(),
    238             image: self.image.clone(),
    239             auth_required: self.auth_required,
    240             authorized: self.authorized,
    241             auth_url: self.auth_url.clone(),
    242             expires_in_secs: self.expires_at.map(remaining_secs),
    243             signer_authority: self.signer_authority.clone(),
    244         }
    245     }
    246 }
    247 
    248 impl Nip46SessionAuthority {
    249     pub fn normalized(mut self) -> Result<Self, String> {
    250         self.provider_runtime_id = self.provider_runtime_id.trim().to_owned();
    251         self.account_identity_id = self.account_identity_id.trim().to_owned();
    252         self.provider_session_id = self
    253             .provider_session_id
    254             .as_deref()
    255             .map(str::trim)
    256             .filter(|value| !value.is_empty())
    257             .map(ToOwned::to_owned);
    258         if self.provider_runtime_id.is_empty() {
    259             return Err("signer_authority.provider_runtime_id cannot be empty".to_owned());
    260         }
    261         if self.account_identity_id.is_empty() {
    262             return Err("signer_authority.account_identity_id cannot be empty".to_owned());
    263         }
    264         Ok(self)
    265     }
    266 }
    267 
    268 fn remaining_secs(expires_at: Instant) -> u64 {
    269     if expires_at <= Instant::now() {
    270         0
    271     } else {
    272         expires_at
    273             .saturating_duration_since(Instant::now())
    274             .as_secs()
    275     }
    276 }
    277 
    278 pub fn filter_perms(requested: &[String], allowed: &[String]) -> Vec<String> {
    279     if allowed.is_empty() {
    280         return Vec::new();
    281     }
    282     let allows_sign_event = allowed.iter().any(|entry| entry == "sign_event");
    283     requested
    284         .iter()
    285         .filter_map(|perm| {
    286             if allowed.iter().any(|allow| allow == perm) {
    287                 return Some(perm.clone());
    288             }
    289             if allows_sign_event && perm.starts_with("sign_event:") {
    290                 return Some(perm.clone());
    291             }
    292             None
    293         })
    294         .collect()
    295 }
    296 
    297 pub fn sign_event_allowed(perms: &[String], kind: u32) -> bool {
    298     if perms.iter().any(|entry| entry == "sign_event") {
    299         return true;
    300     }
    301     let entry = format!("sign_event:{kind}");
    302     perms.iter().any(|perm| perm == &entry)
    303 }
    304 
    305 pub fn session_expires_at(ttl_secs: u64) -> Option<Instant> {
    306     if ttl_secs == 0 {
    307         None
    308     } else {
    309         Some(Instant::now() + Duration::from_secs(ttl_secs))
    310     }
    311 }
    312 
    313 #[cfg(test)]
    314 #[cfg_attr(coverage_nightly, coverage(off))]
    315 mod tests {
    316     use super::*;
    317 
    318     fn build_session(id: &str, expires_at: Option<Instant>) -> Nip46Session {
    319         let keys = RadrootsNostrKeys::generate();
    320         let client = RadrootsNostrClient::new(keys.clone());
    321         let pubkey = keys.public_key();
    322         Nip46Session {
    323             id: id.to_string(),
    324             client,
    325             client_keys: keys,
    326             client_pubkey: pubkey,
    327             remote_signer_pubkey: pubkey,
    328             user_pubkey: None,
    329             relays: Vec::new(),
    330             perms: Vec::new(),
    331             name: None,
    332             url: None,
    333             image: None,
    334             expires_at,
    335             auth_required: false,
    336             authorized: true,
    337             auth_url: None,
    338             pending_request: None,
    339             signer_authority: None,
    340         }
    341     }
    342 
    343     #[tokio::test]
    344     async fn session_store_removes_expired() {
    345         let store = Nip46SessionStore::new();
    346         let session = build_session("expired", Some(Instant::now() - Duration::from_secs(1)));
    347         store.insert(session).await;
    348         let found = store.get("expired").await;
    349         assert!(found.is_none());
    350         let found_again = store.get("expired").await;
    351         assert!(found_again.is_none());
    352     }
    353 
    354     #[test]
    355     fn public_view_marks_inbound_local_signer_sessions() {
    356         let session = build_session("inbound", None);
    357 
    358         let view = session.public_view();
    359 
    360         assert_eq!(view.session_id, "inbound");
    361         assert_eq!(view.role, Nip46SessionRole::InboundLocalSigner);
    362         assert_eq!(view.client_pubkey, session.client_pubkey.to_hex());
    363         assert_eq!(view.signer_pubkey, session.remote_signer_pubkey.to_hex());
    364         assert_eq!(view.permissions, session.perms);
    365     }
    366 
    367     #[test]
    368     fn public_view_marks_outbound_remote_signer_sessions() {
    369         let client_keys = RadrootsNostrKeys::generate();
    370         let remote_signer_keys = RadrootsNostrKeys::generate();
    371         let session = Nip46Session {
    372             id: "outbound".to_string(),
    373             client: RadrootsNostrClient::new(client_keys.clone()),
    374             client_keys: client_keys.clone(),
    375             client_pubkey: client_keys.public_key(),
    376             remote_signer_pubkey: remote_signer_keys.public_key(),
    377             user_pubkey: None,
    378             relays: vec!["wss://relay.example.com".to_string()],
    379             perms: vec!["sign_event".to_string()],
    380             name: Some("remote signer".to_string()),
    381             url: Some("https://signer.example.com".to_string()),
    382             image: None,
    383             expires_at: Some(Instant::now() + Duration::from_secs(30)),
    384             auth_required: true,
    385             authorized: false,
    386             auth_url: Some("https://signer.example.com/auth".to_string()),
    387             pending_request: None,
    388             signer_authority: None,
    389         };
    390 
    391         let view = session.public_view();
    392 
    393         assert_eq!(view.session_id, "outbound");
    394         assert_eq!(view.role, Nip46SessionRole::OutboundRemoteSigner);
    395         assert_eq!(view.client_pubkey, session.client_pubkey.to_hex());
    396         assert_eq!(view.signer_pubkey, session.remote_signer_pubkey.to_hex());
    397         assert_eq!(view.relays, session.relays);
    398         assert_eq!(view.permissions, session.perms);
    399         assert!(view.auth_required);
    400         assert!(!view.authorized);
    401         assert_eq!(view.auth_url, session.auth_url);
    402         assert!(view.expires_in_secs.is_some());
    403     }
    404 
    405     #[test]
    406     fn public_view_keeps_remote_signer_and_user_pubkeys_distinct() {
    407         let client_keys = RadrootsNostrKeys::generate();
    408         let remote_signer_keys = RadrootsNostrKeys::generate();
    409         let user_keys = RadrootsNostrKeys::generate();
    410         let session = Nip46Session {
    411             id: "hydrated-outbound".to_string(),
    412             client: RadrootsNostrClient::new(client_keys.clone()),
    413             client_keys: client_keys.clone(),
    414             client_pubkey: client_keys.public_key(),
    415             remote_signer_pubkey: remote_signer_keys.public_key(),
    416             user_pubkey: Some(user_keys.public_key()),
    417             relays: vec!["wss://relay.example.com".to_string()],
    418             perms: vec!["sign_event:30402".to_string()],
    419             name: Some("remote signer".to_string()),
    420             url: None,
    421             image: None,
    422             expires_at: Some(Instant::now() + Duration::from_secs(30)),
    423             auth_required: false,
    424             authorized: true,
    425             auth_url: None,
    426             pending_request: None,
    427             signer_authority: None,
    428         };
    429 
    430         let view = session.public_view();
    431         let expected_user_pubkey = user_keys.public_key().to_hex();
    432 
    433         assert_eq!(view.role, Nip46SessionRole::OutboundRemoteSigner);
    434         assert_eq!(view.signer_pubkey, remote_signer_keys.public_key().to_hex());
    435         assert_eq!(
    436             view.user_pubkey.as_deref(),
    437             Some(expected_user_pubkey.as_str())
    438         );
    439         assert_ne!(view.signer_pubkey, expected_user_pubkey);
    440     }
    441 
    442     #[tokio::test]
    443     async fn session_store_keeps_active() {
    444         let store = Nip46SessionStore::new();
    445         let session = build_session("active", Some(Instant::now() + Duration::from_secs(60)));
    446         store.insert(session).await;
    447         let found = store.get("active").await;
    448         assert!(found.is_some());
    449     }
    450 
    451     #[tokio::test]
    452     async fn session_store_list_filters_expired() {
    453         let store = Nip46SessionStore::new();
    454         store
    455             .insert(build_session(
    456                 "expired",
    457                 Some(Instant::now() - Duration::from_secs(1)),
    458             ))
    459             .await;
    460         store
    461             .insert(build_session(
    462                 "active",
    463                 Some(Instant::now() + Duration::from_secs(10)),
    464             ))
    465             .await;
    466         let listed = store.list().await;
    467         assert_eq!(listed.len(), 1);
    468         assert_eq!(listed[0].id, "active");
    469     }
    470 
    471     #[test]
    472     fn filter_perms_allows_sign_event_kinds() {
    473         let requested = vec![
    474             "sign_event:1".to_string(),
    475             "sign_event:4".to_string(),
    476             "nip04_encrypt".to_string(),
    477         ];
    478         let allowed = vec!["sign_event".to_string(), "nip04_encrypt".to_string()];
    479         let filtered = filter_perms(&requested, &allowed);
    480         assert_eq!(
    481             filtered,
    482             vec![
    483                 "sign_event:1".to_string(),
    484                 "sign_event:4".to_string(),
    485                 "nip04_encrypt".to_string()
    486             ]
    487         );
    488     }
    489 
    490     #[test]
    491     fn sign_event_allowed_respects_kinds() {
    492         let perms = vec!["sign_event:1".to_string()];
    493         assert!(sign_event_allowed(&perms, 1));
    494         assert!(!sign_event_allowed(&perms, 3));
    495     }
    496 
    497     #[tokio::test]
    498     async fn claim_secret_rejects_reuse() {
    499         let store = Nip46SessionStore::new();
    500         assert!(store.claim_secret("secret").await);
    501         assert!(!store.claim_secret("secret").await);
    502     }
    503 
    504     #[tokio::test]
    505     async fn session_store_remove_reports_presence() {
    506         let store = Nip46SessionStore::new();
    507         store.insert(build_session("remove", None)).await;
    508         assert!(store.remove("remove").await);
    509         assert!(!store.remove("remove").await);
    510     }
    511 
    512     #[test]
    513     fn session_expires_at_handles_zero_and_positive() {
    514         assert!(session_expires_at(0).is_none());
    515         assert!(session_expires_at(10).is_some());
    516     }
    517 
    518     #[test]
    519     fn session_is_expired_respects_future_and_none() {
    520         let session = build_session("active", Some(Instant::now() + Duration::from_secs(1)));
    521         assert!(!session.is_expired());
    522         let session = build_session("never", None);
    523         assert!(!session.is_expired());
    524     }
    525 
    526     #[test]
    527     fn session_is_expired_for_past_deadline() {
    528         let session = build_session("expired", Some(Instant::now() - Duration::from_secs(1)));
    529         assert!(session.is_expired());
    530     }
    531 
    532     #[tokio::test]
    533     async fn session_store_set_user_pubkey_handles_missing_and_expired() {
    534         let store = Nip46SessionStore::new();
    535         let keys = RadrootsNostrKeys::generate();
    536         assert!(!store.set_user_pubkey("missing", keys.public_key()).await);
    537 
    538         let session = build_session(
    539             "expired-user",
    540             Some(Instant::now() - Duration::from_secs(1)),
    541         );
    542         store.insert(session).await;
    543         assert!(
    544             !store
    545                 .set_user_pubkey("expired-user", keys.public_key())
    546                 .await
    547         );
    548     }
    549 
    550     #[tokio::test]
    551     async fn session_store_set_user_pubkey_sets_value_for_active_session() {
    552         let store = Nip46SessionStore::new();
    553         let session = build_session(
    554             "active-user",
    555             Some(Instant::now() + Duration::from_secs(30)),
    556         );
    557         let keys = RadrootsNostrKeys::generate();
    558         let pubkey = keys.public_key();
    559         store.insert(session).await;
    560         assert!(store.set_user_pubkey("active-user", pubkey).await);
    561         let found = store.get("active-user").await.expect("session");
    562         assert_eq!(found.user_pubkey, Some(pubkey));
    563     }
    564 
    565     #[tokio::test]
    566     async fn session_store_require_auth_sets_flags_and_clears_pending() {
    567         let store = Nip46SessionStore::new();
    568         let mut session = build_session("auth", Some(Instant::now() + Duration::from_secs(30)));
    569         let keys = RadrootsNostrKeys::generate();
    570         session.pending_request = Some(PendingNostrRequest {
    571             request_id: "req-1".to_string(),
    572             client_pubkey: keys.public_key(),
    573             request: NostrConnectRequest::Ping,
    574         });
    575         store.insert(session).await;
    576 
    577         assert!(store.require_auth("auth", "https://auth".to_string()).await);
    578         let found = store.get("auth").await.expect("session");
    579         assert!(found.auth_required);
    580         assert!(!found.authorized);
    581         assert_eq!(found.auth_url, Some("https://auth".to_string()));
    582         assert!(found.pending_request.is_none());
    583     }
    584 
    585     #[tokio::test]
    586     async fn session_store_require_auth_handles_missing_and_expired() {
    587         let store = Nip46SessionStore::new();
    588         assert!(
    589             !store
    590                 .require_auth("missing", "https://auth".to_string())
    591                 .await
    592         );
    593 
    594         store
    595             .insert(build_session(
    596                 "expired-auth",
    597                 Some(Instant::now() - Duration::from_secs(1)),
    598             ))
    599             .await;
    600         assert!(
    601             !store
    602                 .require_auth("expired-auth", "https://auth".to_string())
    603                 .await
    604         );
    605     }
    606 
    607     #[tokio::test]
    608     async fn session_store_authorize_returns_pending() {
    609         let store = Nip46SessionStore::new();
    610         let mut session =
    611             build_session("authorize", Some(Instant::now() + Duration::from_secs(30)));
    612         let keys = RadrootsNostrKeys::generate();
    613         session.pending_request = Some(PendingNostrRequest {
    614             request_id: "req-2".to_string(),
    615             client_pubkey: keys.public_key(),
    616             request: NostrConnectRequest::GetPublicKey,
    617         });
    618         store.insert(session).await;
    619 
    620         let outcome = store.authorize("authorize").await.expect("outcome");
    621         assert!(outcome.pending.is_some());
    622         let found = store.get("authorize").await.expect("session");
    623         assert!(found.authorized);
    624     }
    625 
    626     #[tokio::test]
    627     async fn session_store_authorize_handles_missing_and_expired() {
    628         let store = Nip46SessionStore::new();
    629         assert!(store.authorize("missing").await.is_none());
    630 
    631         store
    632             .insert(build_session(
    633                 "expired-authorize",
    634                 Some(Instant::now() - Duration::from_secs(1)),
    635             ))
    636             .await;
    637         assert!(store.authorize("expired-authorize").await.is_none());
    638     }
    639 
    640     #[tokio::test]
    641     async fn session_store_set_pending_request_handles_missing_and_expired() {
    642         let store = Nip46SessionStore::new();
    643         let keys = RadrootsNostrKeys::generate();
    644         let pending = PendingNostrRequest {
    645             request_id: "req-3".to_string(),
    646             client_pubkey: keys.public_key(),
    647             request: NostrConnectRequest::Ping,
    648         };
    649         assert!(!store.set_pending_request("missing", pending.clone()).await);
    650 
    651         let session = build_session(
    652             "expired-pending",
    653             Some(Instant::now() - Duration::from_secs(1)),
    654         );
    655         store.insert(session).await;
    656         assert!(!store.set_pending_request("expired-pending", pending).await);
    657     }
    658 
    659     #[tokio::test]
    660     async fn session_store_set_pending_request_succeeds_for_active_session() {
    661         let store = Nip46SessionStore::new();
    662         store
    663             .insert(build_session(
    664                 "pending",
    665                 Some(Instant::now() + Duration::from_secs(30)),
    666             ))
    667             .await;
    668         let keys = RadrootsNostrKeys::generate();
    669         let pending = PendingNostrRequest {
    670             request_id: "req-active".to_string(),
    671             client_pubkey: keys.public_key(),
    672             request: NostrConnectRequest::Ping,
    673         };
    674         assert!(store.set_pending_request("pending", pending).await);
    675         let found = store.get("pending").await.expect("session");
    676         assert!(found.pending_request.is_some());
    677     }
    678 
    679     #[tokio::test]
    680     async fn session_store_list_sorts_ids() {
    681         let store = Nip46SessionStore::new();
    682         store
    683             .insert(build_session(
    684                 "b",
    685                 Some(Instant::now() + Duration::from_secs(10)),
    686             ))
    687             .await;
    688         store
    689             .insert(build_session(
    690                 "a",
    691                 Some(Instant::now() + Duration::from_secs(10)),
    692             ))
    693             .await;
    694         let listed = store.list().await;
    695         assert_eq!(listed.len(), 2);
    696         assert_eq!(listed[0].id, "a");
    697         assert_eq!(listed[1].id, "b");
    698     }
    699 
    700     #[test]
    701     fn filter_perms_empty_allowed_returns_empty() {
    702         let requested = vec!["nip04_encrypt".to_string()];
    703         let filtered = filter_perms(&requested, &[]);
    704         assert!(filtered.is_empty());
    705     }
    706 
    707     #[test]
    708     fn filter_perms_exact_match_and_rejects_unlisted() {
    709         let requested = vec![
    710             "nip04_encrypt".to_string(),
    711             "nip44_encrypt".to_string(),
    712             "sign_event:1".to_string(),
    713         ];
    714         let allowed = vec!["nip04_encrypt".to_string()];
    715         let filtered = filter_perms(&requested, &allowed);
    716         assert_eq!(filtered, vec!["nip04_encrypt".to_string()]);
    717     }
    718 
    719     #[test]
    720     fn filter_perms_sign_event_global_does_not_allow_unrelated_perm() {
    721         let requested = vec!["nip44_encrypt".to_string()];
    722         let allowed = vec!["sign_event".to_string()];
    723         let filtered = filter_perms(&requested, &allowed);
    724         assert!(filtered.is_empty());
    725     }
    726 
    727     #[test]
    728     fn sign_event_allowed_accepts_global_permission() {
    729         let perms = vec!["sign_event".to_string()];
    730         assert!(sign_event_allowed(&perms, 4));
    731     }
    732 }