lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

model.rs (60315B)


      1 use crate::error::RadrootsNostrSignerError;
      2 use hex::encode as hex_encode;
      3 use nostr::{PublicKey, RelayUrl};
      4 use radroots_identity::RadrootsIdentityPublic;
      5 use radroots_nostr_connect::prelude::{
      6     RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
      7     RadrootsNostrConnectRequestMessage,
      8 };
      9 use serde::{Deserialize, Deserializer, Serialize};
     10 use sha2::{Digest, Sha256};
     11 use std::fmt;
     12 use std::str::FromStr;
     13 use url::Url;
     14 use uuid::Uuid;
     15 
     16 pub const RADROOTS_NOSTR_SIGNER_STORE_VERSION: u32 = 1;
     17 
     18 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
     19 pub struct RadrootsNostrSignerConnectionId(String);
     20 
     21 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
     22 pub struct RadrootsNostrSignerRequestId(String);
     23 
     24 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
     25 pub struct RadrootsNostrSignerWorkflowId(String);
     26 
     27 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     28 pub enum RadrootsNostrSignerApprovalRequirement {
     29     NotRequired,
     30     ExplicitUser,
     31 }
     32 
     33 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     34 pub enum RadrootsNostrSignerApprovalState {
     35     NotRequired,
     36     Pending,
     37     Approved,
     38     Rejected,
     39 }
     40 
     41 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     42 pub enum RadrootsNostrSignerConnectionStatus {
     43     Pending,
     44     Active,
     45     Rejected,
     46     Revoked,
     47 }
     48 
     49 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     50 #[serde(rename_all = "snake_case")]
     51 pub enum RadrootsNostrSignerPublishWorkflowKind {
     52     ConnectSecretFinalization,
     53     AuthReplayFinalization,
     54 }
     55 
     56 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     57 #[serde(rename_all = "snake_case")]
     58 pub enum RadrootsNostrSignerPublishWorkflowState {
     59     PendingPublish,
     60     PublishedPendingFinalize,
     61 }
     62 
     63 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     64 pub enum RadrootsNostrSignerRequestDecision {
     65     Allowed,
     66     Denied,
     67     Challenged,
     68 }
     69 
     70 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
     71 pub enum RadrootsNostrSignerAuthState {
     72     #[default]
     73     NotRequired,
     74     Pending,
     75     Authorized,
     76 }
     77 
     78 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     79 #[serde(rename_all = "snake_case")]
     80 pub enum RadrootsNostrSignerSecretDigestAlgorithm {
     81     Sha256,
     82 }
     83 
     84 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     85 pub struct RadrootsNostrSignerConnectSecretHash {
     86     pub algorithm: RadrootsNostrSignerSecretDigestAlgorithm,
     87     pub digest_hex: String,
     88 }
     89 
     90 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     91 pub struct RadrootsNostrSignerAuthChallenge {
     92     pub auth_url: String,
     93     pub required_at_unix: u64,
     94     #[serde(default, skip_serializing_if = "Option::is_none")]
     95     pub authorized_at_unix: Option<u64>,
     96 }
     97 
     98 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     99 pub struct RadrootsNostrSignerPendingRequest {
    100     pub request_message: RadrootsNostrConnectRequestMessage,
    101     pub created_at_unix: u64,
    102 }
    103 
    104 #[derive(Debug, Clone)]
    105 pub struct RadrootsNostrSignerAuthorizationOutcome {
    106     pub connection: RadrootsNostrSignerConnectionRecord,
    107     pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
    108 }
    109 
    110 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    111 pub struct RadrootsNostrSignerPermissionGrant {
    112     #[serde(
    113         serialize_with = "serialize_permission",
    114         deserialize_with = "deserialize_permission"
    115     )]
    116     pub permission: RadrootsNostrConnectPermission,
    117     pub granted_at_unix: u64,
    118 }
    119 
    120 #[derive(Debug, Clone)]
    121 pub struct RadrootsNostrSignerConnectionDraft {
    122     pub client_public_key: PublicKey,
    123     pub user_identity: RadrootsIdentityPublic,
    124     pub connect_secret: Option<String>,
    125     pub requested_permissions: RadrootsNostrConnectPermissions,
    126     pub relays: Vec<RelayUrl>,
    127     pub approval_requirement: RadrootsNostrSignerApprovalRequirement,
    128 }
    129 
    130 #[derive(Debug, Clone, Serialize, Deserialize)]
    131 pub struct RadrootsNostrSignerConnectionRecord {
    132     pub connection_id: RadrootsNostrSignerConnectionId,
    133     pub client_public_key: PublicKey,
    134     pub signer_identity: RadrootsIdentityPublic,
    135     pub user_identity: RadrootsIdentityPublic,
    136     #[serde(
    137         default,
    138         alias = "connect_secret",
    139         deserialize_with = "deserialize_connect_secret_hash_option",
    140         skip_serializing_if = "Option::is_none"
    141     )]
    142     pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>,
    143     #[serde(default, skip_serializing_if = "Option::is_none")]
    144     pub connect_secret_consumed_at_unix: Option<u64>,
    145     pub requested_permissions: RadrootsNostrConnectPermissions,
    146     #[serde(default)]
    147     pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>,
    148     #[serde(default)]
    149     pub relays: Vec<RelayUrl>,
    150     pub approval_requirement: RadrootsNostrSignerApprovalRequirement,
    151     pub approval_state: RadrootsNostrSignerApprovalState,
    152     #[serde(default)]
    153     pub auth_state: RadrootsNostrSignerAuthState,
    154     #[serde(default, skip_serializing_if = "Option::is_none")]
    155     pub auth_challenge: Option<RadrootsNostrSignerAuthChallenge>,
    156     #[serde(default, skip_serializing_if = "Option::is_none")]
    157     pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
    158     pub status: RadrootsNostrSignerConnectionStatus,
    159     #[serde(default, skip_serializing_if = "Option::is_none")]
    160     pub status_reason: Option<String>,
    161     pub created_at_unix: u64,
    162     pub updated_at_unix: u64,
    163     #[serde(default, skip_serializing_if = "Option::is_none")]
    164     pub last_authenticated_at_unix: Option<u64>,
    165     #[serde(default, skip_serializing_if = "Option::is_none")]
    166     pub last_request_at_unix: Option<u64>,
    167 }
    168 
    169 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    170 pub struct RadrootsNostrSignerRequestAuditRecord {
    171     pub request_id: RadrootsNostrSignerRequestId,
    172     pub connection_id: RadrootsNostrSignerConnectionId,
    173     pub method: RadrootsNostrConnectMethod,
    174     pub decision: RadrootsNostrSignerRequestDecision,
    175     #[serde(default, skip_serializing_if = "Option::is_none")]
    176     pub message: Option<String>,
    177     pub created_at_unix: u64,
    178 }
    179 
    180 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    181 pub struct RadrootsNostrSignerPublishWorkflowRecord {
    182     pub workflow_id: RadrootsNostrSignerWorkflowId,
    183     pub connection_id: RadrootsNostrSignerConnectionId,
    184     pub kind: RadrootsNostrSignerPublishWorkflowKind,
    185     pub state: RadrootsNostrSignerPublishWorkflowState,
    186     #[serde(default, skip_serializing_if = "Option::is_none")]
    187     pub pending_request: Option<RadrootsNostrSignerPendingRequest>,
    188     #[serde(default, skip_serializing_if = "Option::is_none")]
    189     pub authorized_at_unix: Option<u64>,
    190     pub created_at_unix: u64,
    191     pub updated_at_unix: u64,
    192 }
    193 
    194 #[derive(Debug, Clone, Serialize, Deserialize)]
    195 pub struct RadrootsNostrSignerStoreState {
    196     pub version: u32,
    197     pub signer_identity: Option<RadrootsIdentityPublic>,
    198     pub connections: Vec<RadrootsNostrSignerConnectionRecord>,
    199     pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>,
    200     #[serde(default)]
    201     pub publish_workflows: Vec<RadrootsNostrSignerPublishWorkflowRecord>,
    202 }
    203 
    204 #[derive(Debug, Clone, Deserialize)]
    205 #[serde(untagged)]
    206 enum RadrootsNostrSignerConnectSecretHashRepr {
    207     Hash(RadrootsNostrSignerConnectSecretHash),
    208     LegacyPlaintext(String),
    209 }
    210 
    211 impl RadrootsNostrSignerConnectionId {
    212     pub fn new_v7() -> Self {
    213         Self(Uuid::now_v7().to_string())
    214     }
    215 
    216     pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
    217         let trimmed = value.trim();
    218         if trimmed.is_empty() {
    219             return Err(RadrootsNostrSignerError::InvalidConnectionId(
    220                 value.to_owned(),
    221             ));
    222         }
    223         Ok(Self(trimmed.to_owned()))
    224     }
    225 
    226     pub fn as_str(&self) -> &str {
    227         self.0.as_str()
    228     }
    229 
    230     pub fn into_string(self) -> String {
    231         self.0
    232     }
    233 }
    234 
    235 impl fmt::Display for RadrootsNostrSignerConnectionId {
    236     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    237         f.write_str(self.as_str())
    238     }
    239 }
    240 
    241 impl AsRef<str> for RadrootsNostrSignerConnectionId {
    242     fn as_ref(&self) -> &str {
    243         self.as_str()
    244     }
    245 }
    246 
    247 impl FromStr for RadrootsNostrSignerConnectionId {
    248     type Err = RadrootsNostrSignerError;
    249 
    250     fn from_str(value: &str) -> Result<Self, Self::Err> {
    251         Self::parse(value)
    252     }
    253 }
    254 
    255 impl RadrootsNostrSignerRequestId {
    256     pub fn new_v7() -> Self {
    257         Self(Uuid::now_v7().to_string())
    258     }
    259 
    260     pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
    261         let trimmed = value.trim();
    262         if trimmed.is_empty() {
    263             return Err(RadrootsNostrSignerError::InvalidRequestId(value.to_owned()));
    264         }
    265         Ok(Self(trimmed.to_owned()))
    266     }
    267 
    268     pub fn as_str(&self) -> &str {
    269         self.0.as_str()
    270     }
    271 
    272     pub fn into_string(self) -> String {
    273         self.0
    274     }
    275 }
    276 
    277 impl RadrootsNostrSignerWorkflowId {
    278     pub fn new_v7() -> Self {
    279         Self(Uuid::now_v7().to_string())
    280     }
    281 
    282     pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> {
    283         let trimmed = value.trim();
    284         if trimmed.is_empty() {
    285             return Err(RadrootsNostrSignerError::InvalidWorkflowId(
    286                 value.to_owned(),
    287             ));
    288         }
    289         Ok(Self(trimmed.to_owned()))
    290     }
    291 
    292     pub fn as_str(&self) -> &str {
    293         self.0.as_str()
    294     }
    295 
    296     pub fn into_string(self) -> String {
    297         self.0
    298     }
    299 }
    300 
    301 impl fmt::Display for RadrootsNostrSignerWorkflowId {
    302     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    303         f.write_str(self.as_str())
    304     }
    305 }
    306 
    307 impl AsRef<str> for RadrootsNostrSignerWorkflowId {
    308     fn as_ref(&self) -> &str {
    309         self.as_str()
    310     }
    311 }
    312 
    313 impl FromStr for RadrootsNostrSignerWorkflowId {
    314     type Err = RadrootsNostrSignerError;
    315 
    316     fn from_str(value: &str) -> Result<Self, Self::Err> {
    317         Self::parse(value)
    318     }
    319 }
    320 
    321 impl fmt::Display for RadrootsNostrSignerRequestId {
    322     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    323         f.write_str(self.as_str())
    324     }
    325 }
    326 
    327 impl AsRef<str> for RadrootsNostrSignerRequestId {
    328     fn as_ref(&self) -> &str {
    329         self.as_str()
    330     }
    331 }
    332 
    333 impl FromStr for RadrootsNostrSignerRequestId {
    334     type Err = RadrootsNostrSignerError;
    335 
    336     fn from_str(value: &str) -> Result<Self, Self::Err> {
    337         Self::parse(value)
    338     }
    339 }
    340 
    341 impl RadrootsNostrSignerConnectSecretHash {
    342     pub fn from_secret(secret: &str) -> Option<Self> {
    343         normalize_optional_string(secret).map(|normalized| {
    344             let mut hasher = Sha256::new();
    345             hasher.update(normalized.as_bytes());
    346             Self {
    347                 algorithm: RadrootsNostrSignerSecretDigestAlgorithm::Sha256,
    348                 digest_hex: hex_encode(hasher.finalize()),
    349             }
    350         })
    351     }
    352 
    353     pub fn matches_secret(&self, secret: &str) -> bool {
    354         Self::from_secret(secret).as_ref() == Some(self)
    355     }
    356 
    357     fn normalize(self) -> Result<Self, String> {
    358         let digest_hex = self.digest_hex.trim().to_ascii_lowercase();
    359         if digest_hex.len() != 64 || !digest_hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
    360             return Err("invalid connect secret digest".into());
    361         }
    362         Ok(Self {
    363             algorithm: self.algorithm,
    364             digest_hex,
    365         })
    366     }
    367 }
    368 
    369 impl RadrootsNostrSignerAuthChallenge {
    370     pub fn new(auth_url: &str, required_at_unix: u64) -> Result<Self, RadrootsNostrSignerError> {
    371         let auth_url = normalize_optional_string(auth_url)
    372             .ok_or_else(|| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.to_owned()))?;
    373         let auth_url: String = Url::parse(&auth_url)
    374             .map_err(|_| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.clone()))?
    375             .into();
    376         Ok(Self {
    377             auth_url,
    378             required_at_unix,
    379             authorized_at_unix: None,
    380         })
    381     }
    382 
    383     pub fn mark_authorized(&mut self, authorized_at_unix: u64) {
    384         self.authorized_at_unix = Some(authorized_at_unix);
    385     }
    386 }
    387 
    388 impl<'de> Deserialize<'de> for RadrootsNostrSignerAuthChallenge {
    389     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    390     where
    391         D: Deserializer<'de>,
    392     {
    393         #[derive(Deserialize)]
    394         struct RawAuthChallenge {
    395             auth_url: String,
    396             required_at_unix: u64,
    397             #[serde(default)]
    398             authorized_at_unix: Option<u64>,
    399         }
    400 
    401         let raw = RawAuthChallenge::deserialize(deserializer)?;
    402         let mut challenge =
    403             Self::new(&raw.auth_url, raw.required_at_unix).map_err(serde::de::Error::custom)?;
    404         challenge.authorized_at_unix = raw.authorized_at_unix;
    405         Ok(challenge)
    406     }
    407 }
    408 
    409 impl RadrootsNostrSignerPendingRequest {
    410     pub fn new(
    411         request_message: RadrootsNostrConnectRequestMessage,
    412         created_at_unix: u64,
    413     ) -> Result<Self, RadrootsNostrSignerError> {
    414         let normalized_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?;
    415         Ok(Self {
    416             request_message: RadrootsNostrConnectRequestMessage::new(
    417                 normalized_id.as_str(),
    418                 request_message.request,
    419             ),
    420             created_at_unix,
    421         })
    422     }
    423 
    424     pub fn request_message(&self) -> RadrootsNostrConnectRequestMessage {
    425         self.request_message.clone()
    426     }
    427 
    428     pub fn request_id(&self) -> RadrootsNostrSignerRequestId {
    429         RadrootsNostrSignerRequestId::parse(&self.request_message.id)
    430             .expect("pending request ids are validated on construction")
    431     }
    432 }
    433 
    434 impl RadrootsNostrSignerAuthorizationOutcome {
    435     pub fn new(
    436         connection: RadrootsNostrSignerConnectionRecord,
    437         pending_request: Option<RadrootsNostrSignerPendingRequest>,
    438     ) -> Self {
    439         Self {
    440             connection,
    441             pending_request,
    442         }
    443     }
    444 }
    445 
    446 impl RadrootsNostrSignerPermissionGrant {
    447     pub fn new(permission: RadrootsNostrConnectPermission, granted_at_unix: u64) -> Self {
    448         Self {
    449             permission,
    450             granted_at_unix,
    451         }
    452     }
    453 }
    454 
    455 impl RadrootsNostrSignerConnectionDraft {
    456     pub fn new(client_public_key: PublicKey, user_identity: RadrootsIdentityPublic) -> Self {
    457         Self {
    458             client_public_key,
    459             user_identity,
    460             connect_secret: None,
    461             requested_permissions: RadrootsNostrConnectPermissions::default(),
    462             relays: Vec::new(),
    463             approval_requirement: RadrootsNostrSignerApprovalRequirement::NotRequired,
    464         }
    465     }
    466 
    467     pub fn with_connect_secret(mut self, connect_secret: impl Into<String>) -> Self {
    468         self.connect_secret = Some(connect_secret.into());
    469         self
    470     }
    471 
    472     pub fn with_requested_permissions(
    473         mut self,
    474         requested_permissions: RadrootsNostrConnectPermissions,
    475     ) -> Self {
    476         self.requested_permissions = requested_permissions;
    477         self
    478     }
    479 
    480     pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self {
    481         self.relays = relays;
    482         self
    483     }
    484 
    485     pub fn with_approval_requirement(
    486         mut self,
    487         approval_requirement: RadrootsNostrSignerApprovalRequirement,
    488     ) -> Self {
    489         self.approval_requirement = approval_requirement;
    490         self
    491     }
    492 }
    493 
    494 impl RadrootsNostrSignerConnectionRecord {
    495     pub fn new(
    496         connection_id: RadrootsNostrSignerConnectionId,
    497         signer_identity: RadrootsIdentityPublic,
    498         draft: RadrootsNostrSignerConnectionDraft,
    499         created_at_unix: u64,
    500     ) -> Self {
    501         let (approval_state, status) = match draft.approval_requirement {
    502             RadrootsNostrSignerApprovalRequirement::NotRequired => (
    503                 RadrootsNostrSignerApprovalState::NotRequired,
    504                 RadrootsNostrSignerConnectionStatus::Active,
    505             ),
    506             RadrootsNostrSignerApprovalRequirement::ExplicitUser => (
    507                 RadrootsNostrSignerApprovalState::Pending,
    508                 RadrootsNostrSignerConnectionStatus::Pending,
    509             ),
    510         };
    511 
    512         Self {
    513             connection_id,
    514             client_public_key: draft.client_public_key,
    515             signer_identity,
    516             user_identity: draft.user_identity,
    517             connect_secret_hash: draft
    518                 .connect_secret
    519                 .as_deref()
    520                 .and_then(RadrootsNostrSignerConnectSecretHash::from_secret),
    521             connect_secret_consumed_at_unix: None,
    522             requested_permissions: draft.requested_permissions,
    523             granted_permissions: Vec::new(),
    524             relays: draft.relays,
    525             approval_requirement: draft.approval_requirement,
    526             approval_state,
    527             auth_state: RadrootsNostrSignerAuthState::NotRequired,
    528             auth_challenge: None,
    529             pending_request: None,
    530             status,
    531             status_reason: None,
    532             created_at_unix,
    533             updated_at_unix: created_at_unix,
    534             last_authenticated_at_unix: None,
    535             last_request_at_unix: None,
    536         }
    537     }
    538 
    539     pub fn granted_permissions(&self) -> RadrootsNostrConnectPermissions {
    540         self.granted_permissions
    541             .iter()
    542             .map(|grant| grant.permission.clone())
    543             .collect::<Vec<_>>()
    544             .into()
    545     }
    546 
    547     pub fn effective_permissions(&self) -> RadrootsNostrConnectPermissions {
    548         let granted_permissions = self.granted_permissions();
    549         if !granted_permissions.is_empty() {
    550             granted_permissions
    551         } else if self.approval_state == RadrootsNostrSignerApprovalState::NotRequired {
    552             self.requested_permissions.clone()
    553         } else {
    554             RadrootsNostrConnectPermissions::default()
    555         }
    556     }
    557 
    558     pub fn is_terminal(&self) -> bool {
    559         matches!(
    560             self.status,
    561             RadrootsNostrSignerConnectionStatus::Rejected
    562                 | RadrootsNostrSignerConnectionStatus::Revoked
    563         )
    564     }
    565 
    566     pub fn connect_secret_is_consumed(&self) -> bool {
    567         self.connect_secret_hash.is_some() && self.connect_secret_consumed_at_unix.is_some()
    568     }
    569 
    570     pub fn touch_updated(&mut self, updated_at_unix: u64) {
    571         self.updated_at_unix = updated_at_unix;
    572     }
    573 
    574     pub fn mark_authenticated(&mut self, authenticated_at_unix: u64) {
    575         self.last_authenticated_at_unix = Some(authenticated_at_unix);
    576         self.updated_at_unix = authenticated_at_unix;
    577     }
    578 
    579     pub fn mark_request(&mut self, request_at_unix: u64) {
    580         self.last_request_at_unix = Some(request_at_unix);
    581         self.updated_at_unix = request_at_unix;
    582     }
    583 
    584     pub fn mark_connect_secret_consumed(&mut self, consumed_at_unix: u64) {
    585         if self.connect_secret_hash.is_none() || self.connect_secret_consumed_at_unix.is_some() {
    586             return;
    587         }
    588         self.connect_secret_consumed_at_unix = Some(consumed_at_unix);
    589         self.updated_at_unix = consumed_at_unix;
    590     }
    591 
    592     pub fn require_auth_challenge(&mut self, auth_challenge: RadrootsNostrSignerAuthChallenge) {
    593         self.auth_state = RadrootsNostrSignerAuthState::Pending;
    594         self.auth_challenge = Some(auth_challenge.clone());
    595         self.pending_request = None;
    596         self.updated_at_unix = auth_challenge.required_at_unix;
    597     }
    598 
    599     pub fn set_pending_request(&mut self, pending_request: RadrootsNostrSignerPendingRequest) {
    600         self.pending_request = Some(pending_request.clone());
    601         self.updated_at_unix = pending_request.created_at_unix;
    602     }
    603 
    604     pub fn authorize_auth_challenge(
    605         &mut self,
    606         authorized_at_unix: u64,
    607     ) -> Option<RadrootsNostrSignerPendingRequest> {
    608         self.auth_state = RadrootsNostrSignerAuthState::Authorized;
    609         if let Some(auth_challenge) = self.auth_challenge.as_mut() {
    610             auth_challenge.mark_authorized(authorized_at_unix);
    611         }
    612         self.last_authenticated_at_unix = Some(authorized_at_unix);
    613         self.updated_at_unix = authorized_at_unix;
    614         self.pending_request.take()
    615     }
    616 
    617     pub fn restore_pending_auth_challenge(
    618         &mut self,
    619         pending_request: RadrootsNostrSignerPendingRequest,
    620         restored_at_unix: u64,
    621     ) {
    622         self.auth_state = RadrootsNostrSignerAuthState::Pending;
    623         if let Some(auth_challenge) = self.auth_challenge.as_mut() {
    624             let previous_authorized_at_unix = auth_challenge.authorized_at_unix.take();
    625             if self.last_authenticated_at_unix == previous_authorized_at_unix {
    626                 self.last_authenticated_at_unix = None;
    627             }
    628         }
    629         self.pending_request = Some(pending_request);
    630         self.updated_at_unix = restored_at_unix;
    631     }
    632 }
    633 
    634 impl RadrootsNostrSignerRequestAuditRecord {
    635     pub fn new(
    636         request_id: RadrootsNostrSignerRequestId,
    637         connection_id: RadrootsNostrSignerConnectionId,
    638         method: RadrootsNostrConnectMethod,
    639         decision: RadrootsNostrSignerRequestDecision,
    640         message: Option<String>,
    641         created_at_unix: u64,
    642     ) -> Self {
    643         Self {
    644             request_id,
    645             connection_id,
    646             method,
    647             decision,
    648             message,
    649             created_at_unix,
    650         }
    651     }
    652 }
    653 
    654 impl RadrootsNostrSignerPublishWorkflowRecord {
    655     pub fn new_connect_secret_finalization(
    656         connection_id: RadrootsNostrSignerConnectionId,
    657         created_at_unix: u64,
    658     ) -> Self {
    659         Self {
    660             workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
    661             connection_id,
    662             kind: RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization,
    663             state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
    664             pending_request: None,
    665             authorized_at_unix: None,
    666             created_at_unix,
    667             updated_at_unix: created_at_unix,
    668         }
    669     }
    670 
    671     pub fn new_auth_replay_finalization(
    672         connection_id: RadrootsNostrSignerConnectionId,
    673         pending_request: RadrootsNostrSignerPendingRequest,
    674         authorized_at_unix: u64,
    675     ) -> Self {
    676         Self {
    677             workflow_id: RadrootsNostrSignerWorkflowId::new_v7(),
    678             connection_id,
    679             kind: RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization,
    680             state: RadrootsNostrSignerPublishWorkflowState::PendingPublish,
    681             pending_request: Some(pending_request),
    682             authorized_at_unix: Some(authorized_at_unix),
    683             created_at_unix: authorized_at_unix,
    684             updated_at_unix: authorized_at_unix,
    685         }
    686     }
    687 
    688     pub fn mark_published(&mut self, updated_at_unix: u64) {
    689         self.state = RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize;
    690         self.updated_at_unix = updated_at_unix;
    691     }
    692 }
    693 
    694 impl Default for RadrootsNostrSignerStoreState {
    695     fn default() -> Self {
    696         Self {
    697             version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
    698             signer_identity: None,
    699             connections: Vec::new(),
    700             audit_records: Vec::new(),
    701             publish_workflows: Vec::new(),
    702         }
    703     }
    704 }
    705 
    706 fn serialize_permission<S>(
    707     permission: &RadrootsNostrConnectPermission,
    708     serializer: S,
    709 ) -> Result<S::Ok, S::Error>
    710 where
    711     S: serde::Serializer,
    712 {
    713     serializer.serialize_str(&permission.to_string())
    714 }
    715 
    716 fn deserialize_permission<'de, D>(
    717     deserializer: D,
    718 ) -> Result<RadrootsNostrConnectPermission, D::Error>
    719 where
    720     D: serde::Deserializer<'de>,
    721 {
    722     let value = String::deserialize(deserializer)?;
    723     value.parse().map_err(serde::de::Error::custom)
    724 }
    725 
    726 fn deserialize_connect_secret_hash_option<'de, D>(
    727     deserializer: D,
    728 ) -> Result<Option<RadrootsNostrSignerConnectSecretHash>, D::Error>
    729 where
    730     D: Deserializer<'de>,
    731 {
    732     let value = Option::<RadrootsNostrSignerConnectSecretHashRepr>::deserialize(deserializer)?;
    733     match value {
    734         None => Ok(None),
    735         Some(RadrootsNostrSignerConnectSecretHashRepr::Hash(hash)) => {
    736             hash.normalize().map(Some).map_err(serde::de::Error::custom)
    737         }
    738         Some(RadrootsNostrSignerConnectSecretHashRepr::LegacyPlaintext(secret)) => {
    739             Ok(RadrootsNostrSignerConnectSecretHash::from_secret(&secret))
    740         }
    741     }
    742 }
    743 
    744 fn normalize_optional_string(value: &str) -> Option<String> {
    745     let trimmed = value.trim();
    746     if trimmed.is_empty() {
    747         None
    748     } else {
    749         Some(trimmed.to_owned())
    750     }
    751 }
    752 
    753 #[cfg(test)]
    754 mod tests {
    755     use super::*;
    756     use crate::test_support::{
    757         api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key,
    758         primary_relay, synthetic_public_identity, synthetic_public_key,
    759     };
    760     use nostr::PublicKey;
    761     use radroots_identity::RadrootsIdentityPublic;
    762     use serde_json::json;
    763     use std::str::FromStr;
    764     use tempfile::tempdir;
    765 
    766     fn public_identity(index: u32) -> RadrootsIdentityPublic {
    767         synthetic_public_identity(index)
    768     }
    769 
    770     fn public_key(index: u32) -> PublicKey {
    771         synthetic_public_key(index)
    772     }
    773 
    774     fn request_message(id: &str) -> RadrootsNostrConnectRequestMessage {
    775         RadrootsNostrConnectRequestMessage::new(
    776             id,
    777             radroots_nostr_connect::prelude::RadrootsNostrConnectRequest::Ping,
    778         )
    779     }
    780 
    781     #[test]
    782     fn connection_and_request_ids_parse_and_display() {
    783         let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection");
    784         let request_id = RadrootsNostrSignerRequestId::parse("req-1").expect("request");
    785         let workflow_id = RadrootsNostrSignerWorkflowId::parse("wf-1").expect("workflow");
    786 
    787         assert_eq!(connection_id.as_str(), "conn-1");
    788         assert_eq!(request_id.as_str(), "req-1");
    789         assert_eq!(workflow_id.as_str(), "wf-1");
    790         assert_eq!(connection_id.as_ref(), "conn-1");
    791         assert_eq!(request_id.as_ref(), "req-1");
    792         assert_eq!(workflow_id.as_ref(), "wf-1");
    793         assert_eq!(connection_id.to_string(), "conn-1");
    794         assert_eq!(request_id.to_string(), "req-1");
    795         assert_eq!(workflow_id.to_string(), "wf-1");
    796         assert_eq!(connection_id.clone().into_string(), "conn-1");
    797         assert_eq!(request_id.clone().into_string(), "req-1");
    798         assert_eq!(workflow_id.clone().into_string(), "wf-1");
    799 
    800         let parsed_connection =
    801             RadrootsNostrSignerConnectionId::from_str("conn-1").expect("from_str connection");
    802         let parsed_request =
    803             RadrootsNostrSignerRequestId::from_str("req-1").expect("from_str request");
    804         let parsed_workflow =
    805             RadrootsNostrSignerWorkflowId::from_str("wf-1").expect("from_str workflow");
    806         assert_eq!(parsed_connection, connection_id);
    807         assert_eq!(parsed_request, request_id);
    808         assert_eq!(parsed_workflow, workflow_id);
    809     }
    810 
    811     #[test]
    812     fn generated_ids_are_non_empty() {
    813         let connection_id = RadrootsNostrSignerConnectionId::new_v7();
    814         let request_id = RadrootsNostrSignerRequestId::new_v7();
    815         let workflow_id = RadrootsNostrSignerWorkflowId::new_v7();
    816 
    817         assert!(!connection_id.as_ref().is_empty());
    818         assert!(!request_id.as_ref().is_empty());
    819         assert!(!workflow_id.as_ref().is_empty());
    820     }
    821 
    822     #[test]
    823     fn ids_reject_empty_values() {
    824         let connection_err =
    825             RadrootsNostrSignerConnectionId::parse("   ").expect_err("empty connection");
    826         let request_err = RadrootsNostrSignerRequestId::parse("").expect_err("empty request");
    827         let workflow_err = RadrootsNostrSignerWorkflowId::parse(" ").expect_err("empty workflow");
    828 
    829         assert!(connection_err.to_string().contains("invalid connection id"));
    830         assert!(request_err.to_string().contains("invalid request id"));
    831         assert!(workflow_err.to_string().contains("invalid workflow id"));
    832     }
    833 
    834     #[test]
    835     fn connection_draft_builders_apply_values() {
    836         let permission = RadrootsNostrConnectPermission::with_parameter(
    837             RadrootsNostrConnectMethod::SignEvent,
    838             "kind:1",
    839         );
    840         let relay = primary_relay();
    841         let draft = RadrootsNostrSignerConnectionDraft::new(
    842             fixture_carol_public_key(),
    843             fixture_bob_identity(),
    844         )
    845         .with_connect_secret(" secret ")
    846         .with_requested_permissions(vec![permission.clone()].into())
    847         .with_relays(vec![relay.clone()])
    848         .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser);
    849 
    850         assert_eq!(draft.connect_secret.as_deref(), Some(" secret "));
    851         assert_eq!(draft.requested_permissions.as_slice(), &[permission]);
    852         assert_eq!(draft.relays, vec![relay]);
    853         assert_eq!(
    854             draft.approval_requirement,
    855             RadrootsNostrSignerApprovalRequirement::ExplicitUser
    856         );
    857     }
    858 
    859     #[test]
    860     fn connection_record_defaults_follow_approval_requirement_and_tracking_helpers() {
    861         let signer_identity = fixture_alice_identity();
    862         let user_identity = fixture_bob_identity();
    863         let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id");
    864         let draft =
    865             RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity)
    866                 .with_connect_secret(" secret ")
    867                 .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser);
    868         let mut record =
    869             RadrootsNostrSignerConnectionRecord::new(connection_id, signer_identity, draft, 10);
    870 
    871         assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Pending);
    872         assert_eq!(
    873             record.approval_state,
    874             RadrootsNostrSignerApprovalState::Pending
    875         );
    876         assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired);
    877         assert!(
    878             record
    879                 .connect_secret_hash
    880                 .as_ref()
    881                 .expect("connect secret hash")
    882                 .matches_secret("secret")
    883         );
    884         assert!(!record.connect_secret_is_consumed());
    885         assert!(!record.is_terminal());
    886 
    887         record.touch_updated(12);
    888         record.mark_authenticated(14);
    889         record.mark_request(16);
    890         record.mark_connect_secret_consumed(17);
    891         record.require_auth_challenge(
    892             RadrootsNostrSignerAuthChallenge::new(
    893                 format!("{}/path", api_primary_https()).as_str(),
    894                 18,
    895             )
    896             .expect("auth challenge"),
    897         );
    898         record.set_pending_request(
    899             RadrootsNostrSignerPendingRequest::new(request_message("req-1"), 20)
    900                 .expect("pending request"),
    901         );
    902         let replay = record.authorize_auth_challenge(22).expect("replay");
    903         let no_challenge_replay = RadrootsNostrSignerConnectionRecord::new(
    904             RadrootsNostrSignerConnectionId::parse("conn-1b").expect("id"),
    905             public_identity(0x9),
    906             RadrootsNostrSignerConnectionDraft::new(public_key(0x10), public_identity(0x11)),
    907             24,
    908         )
    909         .authorize_auth_challenge(25);
    910 
    911         assert_eq!(record.updated_at_unix, 22);
    912         assert_eq!(record.connect_secret_consumed_at_unix, Some(17));
    913         assert!(record.connect_secret_is_consumed());
    914         assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Authorized);
    915         assert_eq!(
    916             record
    917                 .auth_challenge
    918                 .as_ref()
    919                 .expect("auth challenge")
    920                 .authorized_at_unix,
    921             Some(22)
    922         );
    923         assert!(record.pending_request.is_none());
    924         assert_eq!(record.last_authenticated_at_unix, Some(22));
    925         assert_eq!(record.last_request_at_unix, Some(16));
    926         assert_eq!(replay.request_id().as_str(), "req-1");
    927         assert!(no_challenge_replay.is_none());
    928 
    929         record.restore_pending_auth_challenge(replay, 23);
    930 
    931         assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Pending);
    932         assert_eq!(
    933             record
    934                 .auth_challenge
    935                 .as_ref()
    936                 .expect("restored challenge")
    937                 .authorized_at_unix,
    938             None
    939         );
    940         assert_eq!(record.last_authenticated_at_unix, None);
    941         assert_eq!(record.updated_at_unix, 23);
    942         assert_eq!(
    943             record
    944                 .pending_request
    945                 .as_ref()
    946                 .expect("restored pending request")
    947                 .request_id()
    948                 .as_str(),
    949             "req-1"
    950         );
    951     }
    952 
    953     #[test]
    954     fn connection_record_noop_consumption_and_restore_paths_preserve_state() {
    955         let mut no_secret_record = RadrootsNostrSignerConnectionRecord::new(
    956             RadrootsNostrSignerConnectionId::parse("conn-no-secret").expect("id"),
    957             public_identity(0x12),
    958             RadrootsNostrSignerConnectionDraft::new(public_key(0x13), public_identity(0x14)),
    959             30,
    960         );
    961         let no_secret_updated_at = no_secret_record.updated_at_unix;
    962         assert!(!no_secret_record.connect_secret_is_consumed());
    963 
    964         no_secret_record.mark_connect_secret_consumed(31);
    965 
    966         assert_eq!(no_secret_record.connect_secret_consumed_at_unix, None);
    967         assert_eq!(no_secret_record.updated_at_unix, no_secret_updated_at);
    968         assert!(!no_secret_record.connect_secret_is_consumed());
    969 
    970         let restored_without_challenge =
    971             RadrootsNostrSignerPendingRequest::new(request_message("req-no-challenge"), 32)
    972                 .expect("pending request");
    973         no_secret_record.last_authenticated_at_unix = Some(29);
    974         no_secret_record.restore_pending_auth_challenge(restored_without_challenge.clone(), 33);
    975 
    976         assert_eq!(no_secret_record.last_authenticated_at_unix, Some(29));
    977         assert_eq!(
    978             no_secret_record.pending_request.as_ref(),
    979             Some(&restored_without_challenge)
    980         );
    981         assert_eq!(no_secret_record.updated_at_unix, 33);
    982 
    983         let mut restored_record = RadrootsNostrSignerConnectionRecord::new(
    984             RadrootsNostrSignerConnectionId::parse("conn-restore-preserve").expect("id"),
    985             public_identity(0x15),
    986             RadrootsNostrSignerConnectionDraft::new(public_key(0x16), public_identity(0x17)),
    987             40,
    988         );
    989         restored_record.require_auth_challenge(
    990             RadrootsNostrSignerAuthChallenge::new(
    991                 format!("{}/preserve", api_primary_https()).as_str(),
    992                 41,
    993             )
    994             .expect("auth challenge"),
    995         );
    996         restored_record.set_pending_request(
    997             RadrootsNostrSignerPendingRequest::new(request_message("req-preserve"), 42)
    998                 .expect("pending request"),
    999         );
   1000         let replay = restored_record
   1001             .authorize_auth_challenge(43)
   1002             .expect("authorize challenge");
   1003         restored_record.last_authenticated_at_unix = Some(99);
   1004 
   1005         restored_record.restore_pending_auth_challenge(replay.clone(), 44);
   1006 
   1007         assert_eq!(
   1008             restored_record.auth_state,
   1009             RadrootsNostrSignerAuthState::Pending
   1010         );
   1011         assert_eq!(restored_record.last_authenticated_at_unix, Some(99));
   1012         assert_eq!(
   1013             restored_record
   1014                 .auth_challenge
   1015                 .as_ref()
   1016                 .expect("restored challenge")
   1017                 .authorized_at_unix,
   1018             None
   1019         );
   1020         assert_eq!(restored_record.pending_request.as_ref(), Some(&replay));
   1021         assert_eq!(restored_record.updated_at_unix, 44);
   1022     }
   1023 
   1024     #[test]
   1025     fn granted_permissions_and_request_audit_build_correctly() {
   1026         let permission = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping);
   1027         let grant = RadrootsNostrSignerPermissionGrant::new(permission.clone(), 42);
   1028         let mut record = RadrootsNostrSignerConnectionRecord::new(
   1029             RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"),
   1030             public_identity(0x6),
   1031             RadrootsNostrSignerConnectionDraft::new(public_key(0x7), public_identity(0x8)),
   1032             20,
   1033         );
   1034         record.granted_permissions = vec![grant];
   1035         let audit = RadrootsNostrSignerRequestAuditRecord::new(
   1036             RadrootsNostrSignerRequestId::parse("req-2").expect("request"),
   1037             RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"),
   1038             RadrootsNostrConnectMethod::Ping,
   1039             RadrootsNostrSignerRequestDecision::Allowed,
   1040             Some("ok".into()),
   1041             25,
   1042         );
   1043 
   1044         assert_eq!(record.granted_permissions().as_slice(), &[permission]);
   1045         assert_eq!(audit.message.as_deref(), Some("ok"));
   1046         assert_eq!(audit.created_at_unix, 25);
   1047 
   1048         let json = serde_json::to_string(&record.granted_permissions[0]).expect("serialize grant");
   1049         let decoded: RadrootsNostrSignerPermissionGrant =
   1050             serde_json::from_str(&json).expect("deserialize grant");
   1051         assert_eq!(
   1052             decoded.permission,
   1053             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping)
   1054         );
   1055     }
   1056 
   1057     #[test]
   1058     fn publish_workflow_records_cover_connect_secret_and_auth_replay_lifecycle() {
   1059         let connection_id = RadrootsNostrSignerConnectionId::parse("conn-workflow").expect("id");
   1060         let pending_request =
   1061             RadrootsNostrSignerPendingRequest::new(request_message("req-workflow"), 41)
   1062                 .expect("pending request");
   1063 
   1064         let connect_secret =
   1065             RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
   1066                 connection_id.clone(),
   1067                 40,
   1068             );
   1069         assert_eq!(
   1070             connect_secret.kind,
   1071             RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
   1072         );
   1073         assert_eq!(
   1074             connect_secret.state,
   1075             RadrootsNostrSignerPublishWorkflowState::PendingPublish
   1076         );
   1077         assert!(connect_secret.pending_request.is_none());
   1078         assert!(connect_secret.authorized_at_unix.is_none());
   1079 
   1080         let mut auth_replay =
   1081             RadrootsNostrSignerPublishWorkflowRecord::new_auth_replay_finalization(
   1082                 connection_id,
   1083                 pending_request.clone(),
   1084                 42,
   1085             );
   1086         assert_eq!(
   1087             auth_replay.kind,
   1088             RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
   1089         );
   1090         assert_eq!(
   1091             auth_replay.state,
   1092             RadrootsNostrSignerPublishWorkflowState::PendingPublish
   1093         );
   1094         assert_eq!(auth_replay.pending_request, Some(pending_request));
   1095         assert_eq!(auth_replay.authorized_at_unix, Some(42));
   1096 
   1097         auth_replay.mark_published(43);
   1098         assert_eq!(
   1099             auth_replay.state,
   1100             RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
   1101         );
   1102         assert_eq!(auth_replay.updated_at_unix, 43);
   1103     }
   1104 
   1105     #[test]
   1106     fn effective_permissions_prefers_grants_then_auto_requested_then_empty() {
   1107         let requested: RadrootsNostrConnectPermissions = vec![RadrootsNostrConnectPermission::new(
   1108             RadrootsNostrConnectMethod::Nip04Encrypt,
   1109         )]
   1110         .into();
   1111         let auto_record = RadrootsNostrSignerConnectionRecord::new(
   1112             RadrootsNostrSignerConnectionId::new_v7(),
   1113             public_identity(0x31),
   1114             RadrootsNostrSignerConnectionDraft::new(public_key(0x32), public_identity(0x33))
   1115                 .with_requested_permissions(requested.clone()),
   1116             1,
   1117         );
   1118         assert_eq!(auto_record.effective_permissions(), requested);
   1119 
   1120         let mut granted_record = auto_record.clone();
   1121         granted_record.granted_permissions = vec![RadrootsNostrSignerPermissionGrant::new(
   1122             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
   1123             2,
   1124         )];
   1125         assert_eq!(
   1126             granted_record.effective_permissions(),
   1127             vec![RadrootsNostrConnectPermission::new(
   1128                 RadrootsNostrConnectMethod::Ping
   1129             )]
   1130             .into()
   1131         );
   1132 
   1133         let mut approved_without_grants = auto_record;
   1134         approved_without_grants.approval_state = RadrootsNostrSignerApprovalState::Approved;
   1135         assert!(approved_without_grants.effective_permissions().is_empty());
   1136     }
   1137 
   1138     #[test]
   1139     fn permission_serde_helpers_round_trip_through_wrapper() {
   1140         #[derive(Debug, Serialize, Deserialize)]
   1141         struct PermissionWrapper {
   1142             #[serde(
   1143                 serialize_with = "serialize_permission",
   1144                 deserialize_with = "deserialize_permission"
   1145             )]
   1146             permission: RadrootsNostrConnectPermission,
   1147         }
   1148 
   1149         let wrapper = PermissionWrapper {
   1150             permission: RadrootsNostrConnectPermission::with_parameter(
   1151                 RadrootsNostrConnectMethod::SignEvent,
   1152                 "kind:1",
   1153             ),
   1154         };
   1155 
   1156         let json = serde_json::to_vec_pretty(&wrapper).expect("serialize wrapper");
   1157         let temp = tempdir().expect("tempdir");
   1158         let path = temp.path().join("permission.json");
   1159         std::fs::write(&path, &json).expect("write permission");
   1160         let file = std::fs::File::open(&path).expect("open permission");
   1161         let reader = std::io::BufReader::new(file);
   1162         let decoded: PermissionWrapper =
   1163             serde_json::from_reader(reader).expect("deserialize wrapper");
   1164 
   1165         assert_eq!(decoded.permission, wrapper.permission);
   1166 
   1167         let value = serde_json::to_value(&wrapper).expect("serialize wrapper to value");
   1168         let decoded_from_value: PermissionWrapper =
   1169             serde_json::from_value(value).expect("deserialize wrapper from value");
   1170         assert_eq!(decoded_from_value.permission, wrapper.permission);
   1171 
   1172         let invalid = serde_json::from_str::<PermissionWrapper>(r#"{"permission":1}"#)
   1173             .expect_err("invalid permission type");
   1174         assert!(invalid.to_string().contains("invalid type"));
   1175 
   1176         let invalid_from_value =
   1177             serde_json::from_value::<PermissionWrapper>(json!({ "permission": 1 }))
   1178                 .expect_err("invalid permission type from value");
   1179         assert!(invalid_from_value.to_string().contains("invalid type"));
   1180 
   1181         let invalid_path = temp.path().join("invalid-permission.json");
   1182         std::fs::write(&invalid_path, br#"{"permission":1}"#).expect("write invalid permission");
   1183         let invalid_file = std::fs::File::open(&invalid_path).expect("open invalid permission");
   1184         let invalid_reader = std::io::BufReader::new(invalid_file);
   1185         let invalid_from_reader = serde_json::from_reader::<_, PermissionWrapper>(invalid_reader)
   1186             .expect_err("invalid permission type from reader");
   1187         assert!(invalid_from_reader.to_string().contains("invalid type"));
   1188     }
   1189 
   1190     #[test]
   1191     fn connect_secret_hash_and_pending_request_helpers_validate_inputs() {
   1192         let hash =
   1193             RadrootsNostrSignerConnectSecretHash::from_secret(" secret ").expect("secret hash");
   1194         assert!(hash.matches_secret("secret"));
   1195         assert!(!hash.matches_secret("other"));
   1196         assert!(RadrootsNostrSignerConnectSecretHash::from_secret("   ").is_none());
   1197 
   1198         let pending = RadrootsNostrSignerPendingRequest::new(request_message("req-2"), 30)
   1199             .expect("pending request");
   1200         assert_eq!(pending.request_id().as_str(), "req-2");
   1201         assert_eq!(pending.request_message().id, "req-2");
   1202 
   1203         let invalid_pending = RadrootsNostrSignerPendingRequest::new(request_message("   "), 30)
   1204             .expect_err("invalid pending request id");
   1205         assert!(invalid_pending.to_string().contains("invalid request id"));
   1206 
   1207         let auth_url = format!(" {} ", api_primary_https());
   1208         let challenge =
   1209             RadrootsNostrSignerAuthChallenge::new(auth_url.as_str(), 31).expect("challenge");
   1210         assert_eq!(challenge.auth_url, format!("{}/", api_primary_https()));
   1211 
   1212         let invalid_challenge =
   1213             RadrootsNostrSignerAuthChallenge::new("not-a-url", 31).expect_err("invalid challenge");
   1214         assert!(invalid_challenge.to_string().contains("invalid auth url"));
   1215 
   1216         let empty_challenge =
   1217             RadrootsNostrSignerAuthChallenge::new("   ", 31).expect_err("empty challenge");
   1218         assert!(empty_challenge.to_string().contains("invalid auth url"));
   1219     }
   1220 
   1221     #[test]
   1222     fn auth_challenge_deserialize_rejects_invalid_urls_across_entrypoints() {
   1223         let invalid_json = json!({
   1224             "auth_url": "   ",
   1225             "required_at_unix": 44
   1226         });
   1227 
   1228         let invalid_from_value =
   1229             serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_json.clone())
   1230                 .expect_err("invalid auth challenge from value");
   1231         assert!(invalid_from_value.to_string().contains("invalid auth url"));
   1232 
   1233         let invalid_from_str =
   1234             serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(&invalid_json.to_string())
   1235                 .expect_err("invalid auth challenge from str");
   1236         assert!(invalid_from_str.to_string().contains("invalid auth url"));
   1237 
   1238         let temp = tempdir().expect("tempdir");
   1239         let path = temp.path().join("invalid-auth-challenge.json");
   1240         std::fs::write(
   1241             &path,
   1242             serde_json::to_vec(&invalid_json).expect("serialize invalid auth challenge"),
   1243         )
   1244         .expect("write invalid auth challenge");
   1245         let file = std::fs::File::open(&path).expect("open invalid auth challenge");
   1246         let reader = std::io::BufReader::new(file);
   1247         let invalid_from_reader =
   1248             serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(reader)
   1249                 .expect_err("invalid auth challenge from reader");
   1250         assert!(invalid_from_reader.to_string().contains("invalid auth url"));
   1251 
   1252         let invalid_shape_json = json!({
   1253             "auth_url": 1,
   1254             "required_at_unix": 44
   1255         });
   1256         let invalid_shape_from_value =
   1257             serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_shape_json.clone())
   1258                 .expect_err("invalid auth challenge shape from value");
   1259         assert!(
   1260             invalid_shape_from_value
   1261                 .to_string()
   1262                 .contains("invalid type")
   1263         );
   1264 
   1265         let invalid_shape_from_str = serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(
   1266             &invalid_shape_json.to_string(),
   1267         )
   1268         .expect_err("invalid auth challenge shape from str");
   1269         assert!(invalid_shape_from_str.to_string().contains("invalid type"));
   1270 
   1271         let invalid_shape_path = temp.path().join("invalid-auth-challenge-shape.json");
   1272         std::fs::write(
   1273             &invalid_shape_path,
   1274             serde_json::to_vec(&invalid_shape_json)
   1275                 .expect("serialize invalid auth challenge shape"),
   1276         )
   1277         .expect("write invalid auth challenge shape");
   1278         let invalid_shape_file =
   1279             std::fs::File::open(&invalid_shape_path).expect("open invalid auth challenge shape");
   1280         let invalid_shape_reader = std::io::BufReader::new(invalid_shape_file);
   1281         let invalid_shape_from_reader =
   1282             serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(invalid_shape_reader)
   1283                 .expect_err("invalid auth challenge shape from reader");
   1284         assert!(
   1285             invalid_shape_from_reader
   1286                 .to_string()
   1287                 .contains("invalid type")
   1288         );
   1289     }
   1290 
   1291     #[test]
   1292     fn connection_record_serde_migrates_legacy_connect_secret_and_validates_new_fields() {
   1293         let record_json = json!({
   1294             "connection_id": "conn-legacy",
   1295             "client_public_key": public_key(0x9).to_hex(),
   1296             "signer_identity": public_identity(0x10),
   1297             "user_identity": public_identity(0x11),
   1298             "connect_secret": " legacy-secret ",
   1299             "requested_permissions": "",
   1300             "granted_permissions": [],
   1301             "relays": [],
   1302             "approval_requirement": "NotRequired",
   1303             "approval_state": "NotRequired",
   1304             "status": "Active",
   1305             "status_reason": null,
   1306             "created_at_unix": 1,
   1307             "updated_at_unix": 1,
   1308             "last_authenticated_at_unix": null,
   1309             "last_request_at_unix": null
   1310         });
   1311 
   1312         let decoded_without_secret: RadrootsNostrSignerConnectionRecord =
   1313             serde_json::from_value(json!({
   1314                 "connection_id": "conn-no-secret",
   1315                 "client_public_key": public_key(0x8).to_hex(),
   1316                 "signer_identity": public_identity(0x7),
   1317                 "user_identity": public_identity(0x6),
   1318                 "requested_permissions": "",
   1319                 "granted_permissions": [],
   1320                 "relays": [],
   1321                 "approval_requirement": "NotRequired",
   1322                 "approval_state": "NotRequired",
   1323                 "status": "Active",
   1324                 "created_at_unix": 0,
   1325                 "updated_at_unix": 0,
   1326                 "last_authenticated_at_unix": null,
   1327                 "last_request_at_unix": null
   1328             }))
   1329             .expect("deserialize record without secret");
   1330         assert!(decoded_without_secret.connect_secret_hash.is_none());
   1331         assert!(
   1332             decoded_without_secret
   1333                 .connect_secret_consumed_at_unix
   1334                 .is_none()
   1335         );
   1336 
   1337         let decoded_with_null_secret: RadrootsNostrSignerConnectionRecord =
   1338             serde_json::from_value(json!({
   1339                 "connection_id": "conn-null-secret",
   1340                 "client_public_key": public_key(0x5).to_hex(),
   1341                 "signer_identity": public_identity(0x4),
   1342                 "user_identity": public_identity(0x3),
   1343                 "connect_secret_hash": null,
   1344                 "requested_permissions": "",
   1345                 "granted_permissions": [],
   1346                 "relays": [],
   1347                 "approval_requirement": "NotRequired",
   1348                 "approval_state": "NotRequired",
   1349                 "status": "Active",
   1350                 "created_at_unix": 0,
   1351                 "updated_at_unix": 0,
   1352                 "last_authenticated_at_unix": null,
   1353                 "last_request_at_unix": null
   1354             }))
   1355             .expect("deserialize record with null secret");
   1356         assert!(decoded_with_null_secret.connect_secret_hash.is_none());
   1357         assert!(
   1358             decoded_with_null_secret
   1359                 .connect_secret_consumed_at_unix
   1360                 .is_none()
   1361         );
   1362 
   1363         let decoded: RadrootsNostrSignerConnectionRecord =
   1364             serde_json::from_value(record_json).expect("deserialize legacy record");
   1365         assert!(
   1366             decoded
   1367                 .connect_secret_hash
   1368                 .as_ref()
   1369                 .expect("connect secret hash")
   1370                 .matches_secret("legacy-secret")
   1371         );
   1372 
   1373         let encoded = serde_json::to_value(&decoded).expect("serialize record");
   1374         assert!(encoded.get("connect_secret").is_none());
   1375         assert!(encoded.get("connect_secret_hash").is_some());
   1376         assert!(encoded.get("connect_secret_consumed_at_unix").is_none());
   1377         assert_eq!(
   1378             encoded
   1379                 .get("auth_state")
   1380                 .and_then(serde_json::Value::as_str),
   1381             Some("NotRequired")
   1382         );
   1383 
   1384         let valid_hash = RadrootsNostrSignerConnectSecretHash::from_secret("explicit-secret")
   1385             .expect("valid hash");
   1386         let decoded_new_format: RadrootsNostrSignerConnectionRecord =
   1387             serde_json::from_value(json!({
   1388                 "connection_id": "conn-new",
   1389                 "client_public_key": public_key(0x15).to_hex(),
   1390                 "signer_identity": public_identity(0x16),
   1391                 "user_identity": public_identity(0x17),
   1392                 "connect_secret_hash": {
   1393                     "algorithm": "sha256",
   1394                     "digest_hex": valid_hash.digest_hex
   1395                 },
   1396                 "connect_secret_consumed_at_unix": 23,
   1397                 "requested_permissions": "",
   1398                 "granted_permissions": [],
   1399                 "relays": [],
   1400                 "approval_requirement": "NotRequired",
   1401                 "approval_state": "NotRequired",
   1402                 "status": "Active",
   1403                 "created_at_unix": 3,
   1404                 "updated_at_unix": 3,
   1405                 "last_authenticated_at_unix": null,
   1406                 "last_request_at_unix": null
   1407             }))
   1408             .expect("deserialize new-format record");
   1409         assert!(
   1410             decoded_new_format
   1411                 .connect_secret_hash
   1412                 .as_ref()
   1413                 .expect("new-format hash")
   1414                 .matches_secret("explicit-secret")
   1415         );
   1416         assert_eq!(decoded_new_format.connect_secret_consumed_at_unix, Some(23));
   1417         assert!(decoded_new_format.connect_secret_is_consumed());
   1418 
   1419         let temp = tempdir().expect("tempdir");
   1420         let path = temp.path().join("connection-record.json");
   1421         let reader_json = json!({
   1422             "connection_id": "conn-reader",
   1423             "client_public_key": public_key(0x21).to_hex(),
   1424             "signer_identity": public_identity(0x22),
   1425             "user_identity": public_identity(0x23),
   1426             "connect_secret_hash": {
   1427                 "algorithm": "sha256",
   1428                 "digest_hex": RadrootsNostrSignerConnectSecretHash::from_secret("reader-secret")
   1429                     .expect("reader hash")
   1430                     .digest_hex
   1431             },
   1432             "requested_permissions": "",
   1433             "granted_permissions": [],
   1434             "relays": [],
   1435             "approval_requirement": "NotRequired",
   1436             "approval_state": "NotRequired",
   1437             "auth_state": "Pending",
   1438             "auth_challenge": {
   1439                 "auth_url": format!("{}/reader", api_primary_https()),
   1440                 "required_at_unix": 5
   1441             },
   1442             "status": "Active",
   1443             "created_at_unix": 5,
   1444             "updated_at_unix": 5,
   1445             "last_authenticated_at_unix": null,
   1446             "last_request_at_unix": null
   1447         });
   1448         std::fs::write(
   1449             &path,
   1450             serde_json::to_vec(&reader_json).expect("serialize reader json"),
   1451         )
   1452         .expect("write reader json");
   1453         let file = std::fs::File::open(&path).expect("open reader json");
   1454         let reader = std::io::BufReader::new(file);
   1455         let decoded_from_reader: RadrootsNostrSignerConnectionRecord =
   1456             serde_json::from_reader(reader).expect("deserialize reader record");
   1457         assert!(
   1458             decoded_from_reader
   1459                 .connect_secret_hash
   1460                 .as_ref()
   1461                 .expect("reader hash")
   1462                 .matches_secret("reader-secret")
   1463         );
   1464         assert_eq!(
   1465             decoded_from_reader
   1466                 .auth_challenge
   1467                 .as_ref()
   1468                 .expect("reader auth challenge")
   1469                 .auth_url,
   1470             format!("{}/reader", api_primary_https())
   1471         );
   1472 
   1473         let invalid_hash_json = json!({
   1474             "connection_id": "conn-invalid",
   1475             "client_public_key": public_key(0x12).to_hex(),
   1476             "signer_identity": public_identity(0x13),
   1477             "user_identity": public_identity(0x14),
   1478             "connect_secret_hash": {
   1479                 "algorithm": "sha256",
   1480                 "digest_hex": "not-hex"
   1481             },
   1482             "requested_permissions": "",
   1483             "granted_permissions": [],
   1484             "relays": [],
   1485             "approval_requirement": "NotRequired",
   1486             "approval_state": "NotRequired",
   1487             "status": "Active",
   1488             "auth_state": "Authorized",
   1489             "auth_challenge": {
   1490                 "auth_url": api_primary_https(),
   1491                 "required_at_unix": 2
   1492             },
   1493             "status_reason": null,
   1494             "created_at_unix": 2,
   1495             "updated_at_unix": 2,
   1496             "last_authenticated_at_unix": null,
   1497             "last_request_at_unix": null
   1498         });
   1499         let invalid_hash =
   1500             serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(invalid_hash_json)
   1501                 .expect_err("invalid hash");
   1502         assert!(
   1503             invalid_hash
   1504                 .to_string()
   1505                 .contains("invalid connect secret digest")
   1506         );
   1507 
   1508         let invalid_nonhex_hash =
   1509             serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({
   1510                 "connection_id": "conn-invalid-nonhex",
   1511                 "client_public_key": public_key(0x18).to_hex(),
   1512                 "signer_identity": public_identity(0x19),
   1513                 "user_identity": public_identity(0x20),
   1514                 "connect_secret_hash": {
   1515                     "algorithm": "sha256",
   1516                     "digest_hex": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
   1517                 },
   1518                 "requested_permissions": "",
   1519                 "granted_permissions": [],
   1520                 "relays": [],
   1521                 "approval_requirement": "NotRequired",
   1522                 "approval_state": "NotRequired",
   1523                 "status": "Active",
   1524                 "created_at_unix": 4,
   1525                 "updated_at_unix": 4,
   1526                 "last_authenticated_at_unix": null,
   1527                 "last_request_at_unix": null
   1528             }))
   1529             .expect_err("invalid nonhex hash");
   1530         assert!(
   1531             invalid_nonhex_hash
   1532                 .to_string()
   1533                 .contains("invalid connect secret digest")
   1534         );
   1535 
   1536         let invalid_connect_secret_hash_type =
   1537             serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({
   1538                 "connection_id": "conn-invalid-type",
   1539                 "client_public_key": public_key(0x24).to_hex(),
   1540                 "signer_identity": public_identity(0x25),
   1541                 "user_identity": public_identity(0x26),
   1542                 "connect_secret_hash": 7,
   1543                 "requested_permissions": "",
   1544                 "granted_permissions": [],
   1545                 "relays": [],
   1546                 "approval_requirement": "NotRequired",
   1547                 "approval_state": "NotRequired",
   1548                 "status": "Active",
   1549                 "created_at_unix": 6,
   1550                 "updated_at_unix": 6,
   1551                 "last_authenticated_at_unix": null,
   1552                 "last_request_at_unix": null
   1553             }))
   1554             .expect_err("invalid connect secret hash type");
   1555         assert!(!invalid_connect_secret_hash_type.to_string().is_empty());
   1556 
   1557         let invalid_connect_secret_hash_path = temp.path().join("invalid-connect-secret-type.json");
   1558         std::fs::write(
   1559             &invalid_connect_secret_hash_path,
   1560             serde_json::to_vec(&json!({
   1561                 "connection_id": "conn-invalid-type-reader",
   1562                 "client_public_key": public_key(0x27).to_hex(),
   1563                 "signer_identity": public_identity(0x28),
   1564                 "user_identity": public_identity(0x29),
   1565                 "connect_secret_hash": 9,
   1566                 "requested_permissions": "",
   1567                 "granted_permissions": [],
   1568                 "relays": [],
   1569                 "approval_requirement": "NotRequired",
   1570                 "approval_state": "NotRequired",
   1571                 "status": "Active",
   1572                 "created_at_unix": 7,
   1573                 "updated_at_unix": 7,
   1574                 "last_authenticated_at_unix": null,
   1575                 "last_request_at_unix": null
   1576             }))
   1577             .expect("serialize invalid connect secret hash type"),
   1578         )
   1579         .expect("write invalid connect secret hash type");
   1580         let invalid_connect_secret_hash_file =
   1581             std::fs::File::open(&invalid_connect_secret_hash_path)
   1582                 .expect("open invalid connect secret hash type");
   1583         let invalid_connect_secret_hash_reader =
   1584             std::io::BufReader::new(invalid_connect_secret_hash_file);
   1585         let invalid_connect_secret_hash_from_reader = serde_json::from_reader::<
   1586             _,
   1587             RadrootsNostrSignerConnectionRecord,
   1588         >(invalid_connect_secret_hash_reader)
   1589         .expect_err("invalid connect secret hash type from reader");
   1590         assert!(
   1591             !invalid_connect_secret_hash_from_reader
   1592                 .to_string()
   1593                 .is_empty()
   1594         );
   1595     }
   1596 
   1597     #[test]
   1598     fn store_state_default_is_empty() {
   1599         let state = RadrootsNostrSignerStoreState::default();
   1600         assert_eq!(state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION);
   1601         assert!(state.signer_identity.is_none());
   1602         assert!(state.connections.is_empty());
   1603         assert!(state.audit_records.is_empty());
   1604     }
   1605 }