session.rs (10345B)
1 use crate::{ 2 ActiveIdentitySnapshot, AppCore, AppSnapshot, AppStateRepository, Clock, IdentityRepository, 3 ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition, 4 }; 5 use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; 6 7 impl AppCore { 8 /// Drops the active session while retaining identities, selection, and credentials. 9 /// 10 /// # Errors 11 /// 12 /// Returns a safe application-state error if the transition cannot be applied. 13 pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { 14 if matches!(self.snapshot().session(), crate::SessionState::SignedOut) { 15 return Ok(self.snapshot()); 16 } 17 self.apply_transition(StateTransition::SignOut) 18 } 19 20 /// Validates and prepares a saved local identity before replacing the active session. 21 /// 22 /// # Errors 23 /// 24 /// Returns a safe identity, credential, profile-cache, persistence, or state 25 /// error while preserving any previously active session. 26 pub async fn activate_identity( 27 &self, 28 public_key: PublicKey, 29 identities: &(impl IdentityRepository + ?Sized), 30 app_state: &(impl AppStateRepository + ?Sized), 31 profiles: &(impl ProfileRepository + ?Sized), 32 secrets: &(impl SecretStore + ?Sized), 33 clock: &(impl Clock + ?Sized), 34 ) -> Result<AppSnapshot, SafeError> { 35 let identity = identities 36 .find_identity(public_key) 37 .await? 38 .ok_or_else(identity_not_found)?; 39 self.apply_transition(StateTransition::BeginActivation(public_key))?; 40 let prepared = async { 41 let credential = secrets.load(public_key).await?; 42 let imported = self.key_material().import(credential)?; 43 let (derived_public_key, _npub, canonical_secret) = imported.into_parts(); 44 drop(canonical_secret); 45 if derived_public_key != public_key { 46 return Err(invalid_credential()); 47 } 48 let cached = profiles.load_profile(public_key).await?; 49 let active = ActiveIdentitySnapshot::new( 50 identity.with_last_used_at(clock.now()), 51 RelayConnectionState::Disconnected, 52 if cached.is_some() { 53 ProfileLoadState::Cached 54 } else { 55 ProfileLoadState::Empty 56 }, 57 cached.map(|profile| profile.candidate().metadata().clone()), 58 ); 59 identities.update_identity(active.identity()).await?; 60 app_state.save_selected_identity(Some(public_key)).await?; 61 Ok(active) 62 } 63 .await; 64 match prepared { 65 Ok(active) => { 66 self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active))) 67 } 68 Err(error) => { 69 self.apply_transition(StateTransition::ActivationFailed(error))?; 70 Err(error) 71 } 72 } 73 } 74 } 75 76 const fn identity_not_found() -> SafeError { 77 SafeError::new( 78 SafeErrorCode::IdentityNotFound, 79 SafeMessage::new("The identity was not found."), 80 ) 81 } 82 83 const fn invalid_credential() -> SafeError { 84 SafeError::new( 85 SafeErrorCode::InvalidSecretKey, 86 SafeMessage::new("The Nostr identity credential is invalid."), 87 ) 88 } 89 90 #[cfg(test)] 91 mod tests { 92 use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; 93 94 use crate::{ 95 AppCore, BoxFuture, CachedProfile, Clock, DurableRequestId, InMemoryIdentityRepository, 96 InMemoryOperationJournal, InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, 97 RelayConfiguration, SecretStore, SessionState, 98 }; 99 100 #[derive(Default)] 101 struct EmptyProfiles; 102 103 impl ProfileRepository for EmptyProfiles { 104 fn load_profile( 105 &self, 106 _public_key: PublicKey, 107 ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> { 108 Box::pin(async { Ok(None) }) 109 } 110 111 fn save_profile<'a>( 112 &'a self, 113 _profile: &'a CachedProfile, 114 ) -> BoxFuture<'a, Result<(), SafeError>> { 115 Box::pin(async { Ok(()) }) 116 } 117 118 fn record_refresh_status<'a>( 119 &'a self, 120 _public_key: PublicKey, 121 _refreshed_at: UnixTimestamp, 122 _status: ProfileRefreshStatus, 123 ) -> BoxFuture<'a, Result<(), SafeError>> { 124 Box::pin(async { Ok(()) }) 125 } 126 127 fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> { 128 Box::pin(async { Ok(()) }) 129 } 130 } 131 132 struct FixedClock; 133 134 impl Clock for FixedClock { 135 fn now(&self) -> UnixTimestamp { 136 UnixTimestamp::from_seconds(30).expect("time") 137 } 138 } 139 140 fn input(value: &str) -> SecretKeyInput { 141 SecretKeyInput::parse(value.to_owned()).expect("input") 142 } 143 144 #[tokio::test] 145 async fn activate_identity_switches_only_after_candidate_is_ready() { 146 let core = AppCore::in_memory(RelayConfiguration::default()); 147 let identities = InMemoryIdentityRepository::default(); 148 let secrets = InMemorySecretStore::default(); 149 let journal = InMemoryOperationJournal::default(); 150 let profiles = EmptyProfiles; 151 core.bootstrap().expect("bootstrap"); 152 let first = core 153 .import_secret_key( 154 input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), 155 &identities, 156 &identities, 157 &secrets, 158 &journal, 159 &FixedClock, 160 ) 161 .await 162 .expect("first") 163 .identity() 164 .public_key(); 165 let second = core 166 .import_secret_key( 167 input("1111111111111111111111111111111111111111111111111111111111111111"), 168 &identities, 169 &identities, 170 &secrets, 171 &journal, 172 &FixedClock, 173 ) 174 .await 175 .expect("second") 176 .identity() 177 .public_key(); 178 let activated = core 179 .activate_identity( 180 first, 181 &identities, 182 &identities, 183 &profiles, 184 &secrets, 185 &FixedClock, 186 ) 187 .await 188 .expect("activate first"); 189 assert_eq!(core.snapshot().session(), SessionState::Active); 190 assert_eq!( 191 core.snapshot() 192 .active_identity() 193 .map(|active| active.identity().public_key()), 194 Some(first) 195 ); 196 let registered = activated 197 .identities() 198 .iter() 199 .find(|identity| identity.public_key() == first) 200 .expect("activated identity remains registered"); 201 let active = activated.active_identity().expect("active identity"); 202 assert_eq!(registered, active.identity()); 203 assert_eq!(registered.last_used_at(), Some(FixedClock.now())); 204 205 secrets 206 .delete(&DurableRequestId::new_v7(), second) 207 .await 208 .expect("remove second credential"); 209 let error = core 210 .activate_identity( 211 second, 212 &identities, 213 &identities, 214 &profiles, 215 &secrets, 216 &FixedClock, 217 ) 218 .await 219 .expect_err("missing credential"); 220 assert_eq!( 221 error.code(), 222 harvestcircle_domain::SafeErrorCode::CredentialMissing 223 ); 224 secrets 225 .put( 226 &DurableRequestId::new_v7(), 227 second, 228 input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), 229 ) 230 .await 231 .expect("mismatched credential"); 232 let invalid = core 233 .activate_identity( 234 second, 235 &identities, 236 &identities, 237 &profiles, 238 &secrets, 239 &FixedClock, 240 ) 241 .await 242 .expect_err("mismatched credential"); 243 assert_eq!( 244 invalid.code(), 245 harvestcircle_domain::SafeErrorCode::InvalidSecretKey 246 ); 247 assert_eq!(core.snapshot().session(), SessionState::Active); 248 assert_eq!( 249 core.snapshot() 250 .active_identity() 251 .map(|active| active.identity().public_key()), 252 Some(first) 253 ); 254 } 255 256 #[tokio::test] 257 async fn sign_out_retains_saved_identity_selection_and_credential() { 258 let core = AppCore::in_memory(RelayConfiguration::default()); 259 let identities = InMemoryIdentityRepository::default(); 260 let secrets = InMemorySecretStore::default(); 261 let journal = InMemoryOperationJournal::default(); 262 let profiles = EmptyProfiles; 263 core.bootstrap().expect("bootstrap"); 264 let public_key = core 265 .import_secret_key( 266 input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), 267 &identities, 268 &identities, 269 &secrets, 270 &journal, 271 &FixedClock, 272 ) 273 .await 274 .expect("import") 275 .identity() 276 .public_key(); 277 core.activate_identity( 278 public_key, 279 &identities, 280 &identities, 281 &profiles, 282 &secrets, 283 &FixedClock, 284 ) 285 .await 286 .expect("activate"); 287 288 let signed_out = core.sign_out().expect("sign out"); 289 let repeated = core.sign_out().expect("idempotent sign out"); 290 assert_eq!(signed_out, repeated); 291 assert_eq!(signed_out.session(), SessionState::SignedOut); 292 assert!(signed_out.active_identity().is_none()); 293 assert_eq!(signed_out.identities().len(), 1); 294 assert_eq!(signed_out.selected_identity(), Some(public_key)); 295 assert!( 296 secrets 297 .contains(public_key) 298 .await 299 .expect("credential retained") 300 ); 301 } 302 }