radrootsd

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

session.rs (24677B)


      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 crate::host_nostr::{DaemonNostrClient, Keys, PublicKey};
     11 use nostr::nips::nip46::NostrConnectRequest;
     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: PublicKey,
     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(crate) client: DaemonNostrClient,
     69     pub client_keys: Keys,
     70     pub client_pubkey: PublicKey,
     71     pub remote_signer_pubkey: PublicKey,
     72     pub user_pubkey: Option<PublicKey>,
     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: PublicKey) -> 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 Default for Nip46SessionStore {
    205     fn default() -> Self {
    206         Self::new()
    207     }
    208 }
    209 
    210 impl Nip46Session {
    211     pub fn normalize_authority(
    212         authority: Option<Nip46SessionAuthority>,
    213     ) -> Result<Option<Nip46SessionAuthority>, String> {
    214         authority
    215             .map(|authority| authority.normalized())
    216             .transpose()
    217     }
    218 
    219     pub fn is_expired(&self) -> bool {
    220         self.expires_at
    221             .map(|expires_at| expires_at <= Instant::now())
    222             .unwrap_or(false)
    223     }
    224 
    225     pub fn role(&self) -> Nip46SessionRole {
    226         if self.client_keys.public_key() == self.remote_signer_pubkey {
    227             Nip46SessionRole::InboundLocalSigner
    228         } else {
    229             Nip46SessionRole::OutboundRemoteSigner
    230         }
    231     }
    232 
    233     pub fn public_view(&self) -> Nip46SessionView {
    234         Nip46SessionView {
    235             session_id: self.id.clone(),
    236             role: self.role(),
    237             client_pubkey: self.client_pubkey.to_hex(),
    238             signer_pubkey: self.remote_signer_pubkey.to_hex(),
    239             user_pubkey: self.user_pubkey.as_ref().map(|pubkey| pubkey.to_hex()),
    240             relays: self.relays.clone(),
    241             permissions: self.perms.clone(),
    242             name: self.name.clone(),
    243             url: self.url.clone(),
    244             image: self.image.clone(),
    245             auth_required: self.auth_required,
    246             authorized: self.authorized,
    247             auth_url: self.auth_url.clone(),
    248             expires_in_secs: self.expires_at.map(remaining_secs),
    249             signer_authority: self.signer_authority.clone(),
    250         }
    251     }
    252 }
    253 
    254 impl Nip46SessionAuthority {
    255     pub fn normalized(mut self) -> Result<Self, String> {
    256         self.provider_runtime_id = self.provider_runtime_id.trim().to_owned();
    257         self.account_identity_id = self.account_identity_id.trim().to_owned();
    258         self.provider_session_id = self
    259             .provider_session_id
    260             .as_deref()
    261             .map(str::trim)
    262             .filter(|value| !value.is_empty())
    263             .map(ToOwned::to_owned);
    264         if self.provider_runtime_id.is_empty() {
    265             return Err("signer_authority.provider_runtime_id cannot be empty".to_owned());
    266         }
    267         if self.account_identity_id.is_empty() {
    268             return Err("signer_authority.account_identity_id cannot be empty".to_owned());
    269         }
    270         Ok(self)
    271     }
    272 }
    273 
    274 fn remaining_secs(expires_at: Instant) -> u64 {
    275     if expires_at <= Instant::now() {
    276         0
    277     } else {
    278         expires_at
    279             .saturating_duration_since(Instant::now())
    280             .as_secs()
    281     }
    282 }
    283 
    284 pub fn filter_perms(requested: &[String], allowed: &[String]) -> Vec<String> {
    285     if allowed.is_empty() {
    286         return Vec::new();
    287     }
    288     let allows_sign_event = allowed.iter().any(|entry| entry == "sign_event");
    289     requested
    290         .iter()
    291         .filter_map(|perm| {
    292             if allowed.iter().any(|allow| allow == perm) {
    293                 return Some(perm.clone());
    294             }
    295             if allows_sign_event && perm.starts_with("sign_event:") {
    296                 return Some(perm.clone());
    297             }
    298             None
    299         })
    300         .collect()
    301 }
    302 
    303 pub fn sign_event_allowed(perms: &[String], kind: u32) -> bool {
    304     if perms.iter().any(|entry| entry == "sign_event") {
    305         return true;
    306     }
    307     let entry = format!("sign_event:{kind}");
    308     perms.iter().any(|perm| perm == &entry)
    309 }
    310 
    311 pub fn session_expires_at(ttl_secs: u64) -> Option<Instant> {
    312     if ttl_secs == 0 {
    313         None
    314     } else {
    315         Some(Instant::now() + Duration::from_secs(ttl_secs))
    316     }
    317 }
    318 
    319 #[cfg(test)]
    320 #[cfg_attr(coverage_nightly, coverage(off))]
    321 mod tests {
    322     use super::*;
    323 
    324     fn build_session(id: &str, expires_at: Option<Instant>) -> Nip46Session {
    325         let keys = Keys::generate();
    326         let client = DaemonNostrClient::with_keys(keys.clone());
    327         let pubkey = keys.public_key();
    328         Nip46Session {
    329             id: id.to_string(),
    330             client,
    331             client_keys: keys,
    332             client_pubkey: pubkey,
    333             remote_signer_pubkey: pubkey,
    334             user_pubkey: None,
    335             relays: Vec::new(),
    336             perms: Vec::new(),
    337             name: None,
    338             url: None,
    339             image: None,
    340             expires_at,
    341             auth_required: false,
    342             authorized: true,
    343             auth_url: None,
    344             pending_request: None,
    345             signer_authority: None,
    346         }
    347     }
    348 
    349     #[tokio::test]
    350     async fn session_store_removes_expired() {
    351         let store = Nip46SessionStore::new();
    352         let session = build_session("expired", Some(Instant::now() - Duration::from_secs(1)));
    353         store.insert(session).await;
    354         let found = store.get("expired").await;
    355         assert!(found.is_none());
    356         let found_again = store.get("expired").await;
    357         assert!(found_again.is_none());
    358     }
    359 
    360     #[test]
    361     fn public_view_marks_inbound_local_signer_sessions() {
    362         let session = build_session("inbound", None);
    363 
    364         let view = session.public_view();
    365 
    366         assert_eq!(view.session_id, "inbound");
    367         assert_eq!(view.role, Nip46SessionRole::InboundLocalSigner);
    368         assert_eq!(view.client_pubkey, session.client_pubkey.to_hex());
    369         assert_eq!(view.signer_pubkey, session.remote_signer_pubkey.to_hex());
    370         assert_eq!(view.permissions, session.perms);
    371     }
    372 
    373     #[test]
    374     fn public_view_marks_outbound_remote_signer_sessions() {
    375         let client_keys = Keys::generate();
    376         let remote_signer_keys = Keys::generate();
    377         let session = Nip46Session {
    378             id: "outbound".to_string(),
    379             client: DaemonNostrClient::with_keys(client_keys.clone()),
    380             client_keys: client_keys.clone(),
    381             client_pubkey: client_keys.public_key(),
    382             remote_signer_pubkey: remote_signer_keys.public_key(),
    383             user_pubkey: None,
    384             relays: vec!["wss://relay.example.com".to_string()],
    385             perms: vec!["sign_event".to_string()],
    386             name: Some("remote signer".to_string()),
    387             url: Some("https://signer.example.com".to_string()),
    388             image: None,
    389             expires_at: Some(Instant::now() + Duration::from_secs(30)),
    390             auth_required: true,
    391             authorized: false,
    392             auth_url: Some("https://signer.example.com/auth".to_string()),
    393             pending_request: None,
    394             signer_authority: None,
    395         };
    396 
    397         let view = session.public_view();
    398 
    399         assert_eq!(view.session_id, "outbound");
    400         assert_eq!(view.role, Nip46SessionRole::OutboundRemoteSigner);
    401         assert_eq!(view.client_pubkey, session.client_pubkey.to_hex());
    402         assert_eq!(view.signer_pubkey, session.remote_signer_pubkey.to_hex());
    403         assert_eq!(view.relays, session.relays);
    404         assert_eq!(view.permissions, session.perms);
    405         assert!(view.auth_required);
    406         assert!(!view.authorized);
    407         assert_eq!(view.auth_url, session.auth_url);
    408         assert!(view.expires_in_secs.is_some());
    409     }
    410 
    411     #[test]
    412     fn public_view_keeps_remote_signer_and_user_pubkeys_distinct() {
    413         let client_keys = Keys::generate();
    414         let remote_signer_keys = Keys::generate();
    415         let user_keys = Keys::generate();
    416         let session = Nip46Session {
    417             id: "hydrated-outbound".to_string(),
    418             client: DaemonNostrClient::with_keys(client_keys.clone()),
    419             client_keys: client_keys.clone(),
    420             client_pubkey: client_keys.public_key(),
    421             remote_signer_pubkey: remote_signer_keys.public_key(),
    422             user_pubkey: Some(user_keys.public_key()),
    423             relays: vec!["wss://relay.example.com".to_string()],
    424             perms: vec!["sign_event:30402".to_string()],
    425             name: Some("remote signer".to_string()),
    426             url: None,
    427             image: None,
    428             expires_at: Some(Instant::now() + Duration::from_secs(30)),
    429             auth_required: false,
    430             authorized: true,
    431             auth_url: None,
    432             pending_request: None,
    433             signer_authority: None,
    434         };
    435 
    436         let view = session.public_view();
    437         let expected_user_pubkey = user_keys.public_key().to_hex();
    438 
    439         assert_eq!(view.role, Nip46SessionRole::OutboundRemoteSigner);
    440         assert_eq!(view.signer_pubkey, remote_signer_keys.public_key().to_hex());
    441         assert_eq!(
    442             view.user_pubkey.as_deref(),
    443             Some(expected_user_pubkey.as_str())
    444         );
    445         assert_ne!(view.signer_pubkey, expected_user_pubkey);
    446     }
    447 
    448     #[tokio::test]
    449     async fn session_store_keeps_active() {
    450         let store = Nip46SessionStore::new();
    451         let session = build_session("active", Some(Instant::now() + Duration::from_secs(60)));
    452         store.insert(session).await;
    453         let found = store.get("active").await;
    454         assert!(found.is_some());
    455     }
    456 
    457     #[tokio::test]
    458     async fn session_store_list_filters_expired() {
    459         let store = Nip46SessionStore::new();
    460         store
    461             .insert(build_session(
    462                 "expired",
    463                 Some(Instant::now() - Duration::from_secs(1)),
    464             ))
    465             .await;
    466         store
    467             .insert(build_session(
    468                 "active",
    469                 Some(Instant::now() + Duration::from_secs(10)),
    470             ))
    471             .await;
    472         let listed = store.list().await;
    473         assert_eq!(listed.len(), 1);
    474         assert_eq!(listed[0].id, "active");
    475     }
    476 
    477     #[test]
    478     fn filter_perms_allows_sign_event_kinds() {
    479         let requested = vec![
    480             "sign_event:1".to_string(),
    481             "sign_event:4".to_string(),
    482             "nip04_encrypt".to_string(),
    483         ];
    484         let allowed = vec!["sign_event".to_string(), "nip04_encrypt".to_string()];
    485         let filtered = filter_perms(&requested, &allowed);
    486         assert_eq!(
    487             filtered,
    488             vec![
    489                 "sign_event:1".to_string(),
    490                 "sign_event:4".to_string(),
    491                 "nip04_encrypt".to_string()
    492             ]
    493         );
    494     }
    495 
    496     #[test]
    497     fn sign_event_allowed_respects_kinds() {
    498         let perms = vec!["sign_event:1".to_string()];
    499         assert!(sign_event_allowed(&perms, 1));
    500         assert!(!sign_event_allowed(&perms, 3));
    501     }
    502 
    503     #[tokio::test]
    504     async fn claim_secret_rejects_reuse() {
    505         let store = Nip46SessionStore::new();
    506         assert!(store.claim_secret("secret").await);
    507         assert!(!store.claim_secret("secret").await);
    508     }
    509 
    510     #[tokio::test]
    511     async fn session_store_remove_reports_presence() {
    512         let store = Nip46SessionStore::new();
    513         store.insert(build_session("remove", None)).await;
    514         assert!(store.remove("remove").await);
    515         assert!(!store.remove("remove").await);
    516     }
    517 
    518     #[test]
    519     fn session_expires_at_handles_zero_and_positive() {
    520         assert!(session_expires_at(0).is_none());
    521         assert!(session_expires_at(10).is_some());
    522     }
    523 
    524     #[test]
    525     fn session_is_expired_respects_future_and_none() {
    526         let session = build_session("active", Some(Instant::now() + Duration::from_secs(1)));
    527         assert!(!session.is_expired());
    528         let session = build_session("never", None);
    529         assert!(!session.is_expired());
    530     }
    531 
    532     #[test]
    533     fn session_is_expired_for_past_deadline() {
    534         let session = build_session("expired", Some(Instant::now() - Duration::from_secs(1)));
    535         assert!(session.is_expired());
    536     }
    537 
    538     #[tokio::test]
    539     async fn session_store_set_user_pubkey_handles_missing_and_expired() {
    540         let store = Nip46SessionStore::new();
    541         let keys = Keys::generate();
    542         assert!(!store.set_user_pubkey("missing", keys.public_key()).await);
    543 
    544         let session = build_session(
    545             "expired-user",
    546             Some(Instant::now() - Duration::from_secs(1)),
    547         );
    548         store.insert(session).await;
    549         assert!(
    550             !store
    551                 .set_user_pubkey("expired-user", keys.public_key())
    552                 .await
    553         );
    554     }
    555 
    556     #[tokio::test]
    557     async fn session_store_set_user_pubkey_sets_value_for_active_session() {
    558         let store = Nip46SessionStore::new();
    559         let session = build_session(
    560             "active-user",
    561             Some(Instant::now() + Duration::from_secs(30)),
    562         );
    563         let keys = Keys::generate();
    564         let pubkey = keys.public_key();
    565         store.insert(session).await;
    566         assert!(store.set_user_pubkey("active-user", pubkey).await);
    567         let found = store.get("active-user").await.expect("session");
    568         assert_eq!(found.user_pubkey, Some(pubkey));
    569     }
    570 
    571     #[tokio::test]
    572     async fn session_store_require_auth_sets_flags_and_clears_pending() {
    573         let store = Nip46SessionStore::new();
    574         let mut session = build_session("auth", Some(Instant::now() + Duration::from_secs(30)));
    575         let keys = Keys::generate();
    576         session.pending_request = Some(PendingNostrRequest {
    577             request_id: "req-1".to_string(),
    578             client_pubkey: keys.public_key(),
    579             request: NostrConnectRequest::Ping,
    580         });
    581         store.insert(session).await;
    582 
    583         assert!(store.require_auth("auth", "https://auth".to_string()).await);
    584         let found = store.get("auth").await.expect("session");
    585         assert!(found.auth_required);
    586         assert!(!found.authorized);
    587         assert_eq!(found.auth_url, Some("https://auth".to_string()));
    588         assert!(found.pending_request.is_none());
    589     }
    590 
    591     #[tokio::test]
    592     async fn session_store_require_auth_handles_missing_and_expired() {
    593         let store = Nip46SessionStore::new();
    594         assert!(
    595             !store
    596                 .require_auth("missing", "https://auth".to_string())
    597                 .await
    598         );
    599 
    600         store
    601             .insert(build_session(
    602                 "expired-auth",
    603                 Some(Instant::now() - Duration::from_secs(1)),
    604             ))
    605             .await;
    606         assert!(
    607             !store
    608                 .require_auth("expired-auth", "https://auth".to_string())
    609                 .await
    610         );
    611     }
    612 
    613     #[tokio::test]
    614     async fn session_store_authorize_returns_pending() {
    615         let store = Nip46SessionStore::new();
    616         let mut session =
    617             build_session("authorize", Some(Instant::now() + Duration::from_secs(30)));
    618         let keys = Keys::generate();
    619         session.pending_request = Some(PendingNostrRequest {
    620             request_id: "req-2".to_string(),
    621             client_pubkey: keys.public_key(),
    622             request: NostrConnectRequest::GetPublicKey,
    623         });
    624         store.insert(session).await;
    625 
    626         let outcome = store.authorize("authorize").await.expect("outcome");
    627         assert!(outcome.pending.is_some());
    628         let found = store.get("authorize").await.expect("session");
    629         assert!(found.authorized);
    630     }
    631 
    632     #[tokio::test]
    633     async fn session_store_authorize_handles_missing_and_expired() {
    634         let store = Nip46SessionStore::new();
    635         assert!(store.authorize("missing").await.is_none());
    636 
    637         store
    638             .insert(build_session(
    639                 "expired-authorize",
    640                 Some(Instant::now() - Duration::from_secs(1)),
    641             ))
    642             .await;
    643         assert!(store.authorize("expired-authorize").await.is_none());
    644     }
    645 
    646     #[tokio::test]
    647     async fn session_store_set_pending_request_handles_missing_and_expired() {
    648         let store = Nip46SessionStore::new();
    649         let keys = Keys::generate();
    650         let pending = PendingNostrRequest {
    651             request_id: "req-3".to_string(),
    652             client_pubkey: keys.public_key(),
    653             request: NostrConnectRequest::Ping,
    654         };
    655         assert!(!store.set_pending_request("missing", pending.clone()).await);
    656 
    657         let session = build_session(
    658             "expired-pending",
    659             Some(Instant::now() - Duration::from_secs(1)),
    660         );
    661         store.insert(session).await;
    662         assert!(!store.set_pending_request("expired-pending", pending).await);
    663     }
    664 
    665     #[tokio::test]
    666     async fn session_store_set_pending_request_succeeds_for_active_session() {
    667         let store = Nip46SessionStore::new();
    668         store
    669             .insert(build_session(
    670                 "pending",
    671                 Some(Instant::now() + Duration::from_secs(30)),
    672             ))
    673             .await;
    674         let keys = Keys::generate();
    675         let pending = PendingNostrRequest {
    676             request_id: "req-active".to_string(),
    677             client_pubkey: keys.public_key(),
    678             request: NostrConnectRequest::Ping,
    679         };
    680         assert!(store.set_pending_request("pending", pending).await);
    681         let found = store.get("pending").await.expect("session");
    682         assert!(found.pending_request.is_some());
    683     }
    684 
    685     #[tokio::test]
    686     async fn session_store_list_sorts_ids() {
    687         let store = Nip46SessionStore::new();
    688         store
    689             .insert(build_session(
    690                 "b",
    691                 Some(Instant::now() + Duration::from_secs(10)),
    692             ))
    693             .await;
    694         store
    695             .insert(build_session(
    696                 "a",
    697                 Some(Instant::now() + Duration::from_secs(10)),
    698             ))
    699             .await;
    700         let listed = store.list().await;
    701         assert_eq!(listed.len(), 2);
    702         assert_eq!(listed[0].id, "a");
    703         assert_eq!(listed[1].id, "b");
    704     }
    705 
    706     #[test]
    707     fn filter_perms_empty_allowed_returns_empty() {
    708         let requested = vec!["nip04_encrypt".to_string()];
    709         let filtered = filter_perms(&requested, &[]);
    710         assert!(filtered.is_empty());
    711     }
    712 
    713     #[test]
    714     fn filter_perms_exact_match_and_rejects_unlisted() {
    715         let requested = vec![
    716             "nip04_encrypt".to_string(),
    717             "nip44_encrypt".to_string(),
    718             "sign_event:1".to_string(),
    719         ];
    720         let allowed = vec!["nip04_encrypt".to_string()];
    721         let filtered = filter_perms(&requested, &allowed);
    722         assert_eq!(filtered, vec!["nip04_encrypt".to_string()]);
    723     }
    724 
    725     #[test]
    726     fn filter_perms_sign_event_global_does_not_allow_unrelated_perm() {
    727         let requested = vec!["nip44_encrypt".to_string()];
    728         let allowed = vec!["sign_event".to_string()];
    729         let filtered = filter_perms(&requested, &allowed);
    730         assert!(filtered.is_empty());
    731     }
    732 
    733     #[test]
    734     fn sign_event_allowed_accepts_global_permission() {
    735         let perms = vec!["sign_event".to_string()];
    736         assert!(sign_event_allowed(&perms, 4));
    737     }
    738 }