app_core.rs (12726B)
1 use std::collections::BTreeMap; 2 use std::sync::{Arc, Mutex, MutexGuard}; 3 4 use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; 5 6 use crate::{ 7 AppSnapshot, AppStateRepository, IdentityRepository, KeyMaterialProvider, RelayConfiguration, 8 SnapshotRevision, StateMachine, StateTransition, 9 }; 10 11 pub struct RemovalConfirmationToken { 12 id: u64, 13 public_key: PublicKey, 14 revision: SnapshotRevision, 15 expires_at: UnixTimestamp, 16 impact: RemovalImpact, 17 } 18 19 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 20 pub struct RemovalImpact { 21 deletes_local_credential: bool, 22 signs_out: bool, 23 } 24 25 impl RemovalImpact { 26 #[must_use] 27 pub const fn deletes_local_credential(self) -> bool { 28 self.deletes_local_credential 29 } 30 #[must_use] 31 pub const fn signs_out(self) -> bool { 32 self.signs_out 33 } 34 } 35 36 impl RemovalConfirmationToken { 37 #[must_use] 38 pub const fn public_key(&self) -> PublicKey { 39 self.public_key 40 } 41 #[must_use] 42 pub const fn revision(&self) -> SnapshotRevision { 43 self.revision 44 } 45 #[must_use] 46 pub const fn expires_at(&self) -> UnixTimestamp { 47 self.expires_at 48 } 49 #[must_use] 50 pub const fn impact(&self) -> RemovalImpact { 51 self.impact 52 } 53 } 54 55 #[derive(Clone, Copy)] 56 struct RemovalTokenState { 57 public_key: PublicKey, 58 revision: SnapshotRevision, 59 expires_at: UnixTimestamp, 60 impact: RemovalImpact, 61 } 62 63 struct CoreState { 64 state_machine: StateMachine, 65 removal_tokens: BTreeMap<u64, RemovalTokenState>, 66 next_removal_token: u64, 67 } 68 69 pub struct AppCore { 70 relay_configuration: RelayConfiguration, 71 key_material: Arc<dyn KeyMaterialProvider>, 72 state: Mutex<CoreState>, 73 published_snapshot: tokio::sync::watch::Sender<AppSnapshot>, 74 } 75 76 impl AppCore { 77 #[must_use] 78 pub fn new( 79 relay_configuration: RelayConfiguration, 80 key_material: Arc<dyn KeyMaterialProvider>, 81 ) -> Self { 82 let state_machine = StateMachine::booting(); 83 let (published_snapshot, _) = tokio::sync::watch::channel(state_machine.snapshot().clone()); 84 Self { 85 relay_configuration, 86 key_material, 87 state: Mutex::new(CoreState { 88 state_machine, 89 removal_tokens: BTreeMap::new(), 90 next_removal_token: 1, 91 }), 92 published_snapshot, 93 } 94 } 95 96 #[cfg(test)] 97 #[must_use] 98 pub fn in_memory(relay_configuration: RelayConfiguration) -> Self { 99 Self::new( 100 relay_configuration, 101 Arc::new(crate::test_support::TestKeyMaterialProvider::default()), 102 ) 103 } 104 105 pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { 106 self.key_material.as_ref() 107 } 108 109 /// Moves the in-memory core from booting to an empty ready snapshot. 110 /// 111 /// # Errors 112 /// 113 /// Returns a safe application-state error if the ready snapshot invariant 114 /// cannot be constructed. 115 pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { 116 self.apply_transition(StateTransition::Bootstrap) 117 } 118 119 /// Loads the durable public registry and selection into a signed-out snapshot. 120 /// 121 /// # Errors 122 /// 123 /// Returns the safe persistence error after publishing a fatal snapshot when 124 /// durable state cannot be read or violates application invariants. 125 pub async fn bootstrap_from( 126 &self, 127 identities: &(impl IdentityRepository + ?Sized), 128 app_state: &(impl AppStateRepository + ?Sized), 129 ) -> Result<AppSnapshot, SafeError> { 130 let loaded = async { 131 let identities = identities.list_identities().await?; 132 let selected = app_state.load_selected_identity().await?; 133 Ok::<_, SafeError>((identities, selected)) 134 } 135 .await; 136 match loaded { 137 Ok((identities, selected)) => { 138 self.apply_transition(StateTransition::BootstrapRegistry { 139 identities, 140 selected, 141 }) 142 } 143 Err(error) => { 144 self.apply_transition(StateTransition::Fatal(error))?; 145 Err(error) 146 } 147 } 148 } 149 150 #[must_use] 151 pub fn snapshot(&self) -> AppSnapshot { 152 self.published_snapshot.borrow().clone() 153 } 154 155 pub(crate) fn apply_transition( 156 &self, 157 transition: StateTransition, 158 ) -> Result<AppSnapshot, SafeError> { 159 let snapshot = self 160 .lock_state()? 161 .state_machine 162 .apply(transition, &self.relay_configuration)?; 163 self.published_snapshot.send_replace(snapshot.clone()); 164 Ok(snapshot) 165 } 166 167 pub(crate) fn issue_removal_token( 168 &self, 169 public_key: PublicKey, 170 now: UnixTimestamp, 171 ) -> Result<RemovalConfirmationToken, SafeError> { 172 let mut state = self.lock_state()?; 173 let Some(identity) = state 174 .state_machine 175 .snapshot() 176 .identities() 177 .iter() 178 .find(|identity| identity.public_key() == public_key) 179 else { 180 return Err(identity_not_found()); 181 }; 182 let deletes_local_credential = 183 identity 184 .signer_binding() 185 .as_local_keyring() 186 .is_some_and(|binding| { 187 binding.availability() 188 != harvestcircle_domain::SignerAvailability::CredentialMissing 189 }); 190 let id = state.next_removal_token; 191 state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; 192 let revision = state.state_machine.snapshot().revision(); 193 let expires_at = UnixTimestamp::from_seconds( 194 now.as_seconds() 195 .checked_add(300) 196 .ok_or_else(invalid_application_state)?, 197 ) 198 .ok_or_else(invalid_application_state)?; 199 let impact = RemovalImpact { 200 deletes_local_credential, 201 signs_out: state 202 .state_machine 203 .snapshot() 204 .active_identity() 205 .is_some_and(|active| active.identity().public_key() == public_key), 206 }; 207 state.removal_tokens.insert( 208 id, 209 RemovalTokenState { 210 public_key, 211 revision, 212 expires_at, 213 impact, 214 }, 215 ); 216 Ok(RemovalConfirmationToken { 217 id, 218 public_key, 219 revision, 220 expires_at, 221 impact, 222 }) 223 } 224 225 #[allow(clippy::needless_pass_by_value)] 226 pub(crate) fn consume_removal_token( 227 &self, 228 token: RemovalConfirmationToken, 229 now: UnixTimestamp, 230 ) -> Result<PublicKey, SafeError> { 231 let RemovalConfirmationToken { 232 id, 233 public_key, 234 revision, 235 expires_at, 236 impact, 237 } = token; 238 let mut state = self.lock_state()?; 239 let stored = state.removal_tokens.remove(&id); 240 if stored.is_none_or(|stored| { 241 stored.public_key != public_key 242 || stored.revision != revision 243 || stored.expires_at != expires_at 244 || stored.impact != impact 245 }) || state.state_machine.snapshot().revision() != revision 246 || now.as_seconds() > expires_at.as_seconds() 247 { 248 return Err(invalid_application_state()); 249 } 250 Ok(public_key) 251 } 252 253 #[allow(clippy::needless_pass_by_value)] 254 pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool { 255 self.state 256 .lock() 257 .map(|mut state| state.removal_tokens.remove(&token.id).is_some()) 258 .unwrap_or(false) 259 } 260 261 fn lock_state(&self) -> Result<MutexGuard<'_, CoreState>, SafeError> { 262 self.state.lock().map_err(|_| internal_state_unavailable()) 263 } 264 } 265 266 const fn internal_state_unavailable() -> SafeError { 267 SafeError::new( 268 SafeErrorCode::InvalidApplicationState, 269 SafeMessage::new("The application state is unavailable."), 270 ) 271 } 272 273 const fn invalid_application_state() -> SafeError { 274 SafeError::new( 275 SafeErrorCode::InvalidApplicationState, 276 SafeMessage::new("The identity removal confirmation is no longer valid."), 277 ) 278 } 279 280 const fn identity_not_found() -> SafeError { 281 SafeError::new( 282 SafeErrorCode::IdentityNotFound, 283 SafeMessage::new("The identity was not found."), 284 ) 285 } 286 287 #[cfg(test)] 288 mod tests { 289 use harvestcircle_domain::{ 290 IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, 291 SignerAvailability, UnixTimestamp, 292 }; 293 294 use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; 295 296 #[tokio::test] 297 async fn bootstrap_is_idempotent_and_advances_only_once() { 298 let core = AppCore::in_memory(RelayConfiguration::default()); 299 let ready = core.bootstrap().expect("bootstrap"); 300 let repeated = core.bootstrap().expect("idempotent bootstrap"); 301 302 assert_eq!(ready.lifecycle(), AppLifecycle::Ready); 303 assert_eq!(ready.revision().value(), 1); 304 assert_eq!(repeated, ready); 305 } 306 307 #[tokio::test] 308 async fn core_instances_never_share_state() { 309 let first = AppCore::in_memory(RelayConfiguration::default()); 310 let second = AppCore::in_memory(RelayConfiguration::default()); 311 312 first.bootstrap().expect("first bootstrap"); 313 314 assert_eq!(first.snapshot().revision().value(), 1); 315 assert_eq!(second.snapshot().revision().value(), 0); 316 } 317 318 #[tokio::test] 319 async fn removal_impact_matches_missing_local_binding() { 320 let core = AppCore::in_memory(RelayConfiguration::default()); 321 let public_key = crate::test_support::valid_test_public_key(9).expect("valid public key"); 322 let identity = NostrIdentity::new( 323 NostrIdentityReference::derive(public_key).expect("identity"), 324 LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), 325 None, 326 IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), 327 None, 328 ) 329 .expect("identity"); 330 core.apply_transition(StateTransition::BootstrapRegistry { 331 identities: vec![identity], 332 selected: Some(public_key), 333 }) 334 .expect("registry"); 335 336 let removal = core 337 .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time")) 338 .expect("removal"); 339 340 assert!(!removal.impact().deletes_local_credential()); 341 assert!(!removal.impact().signs_out()); 342 } 343 344 #[tokio::test] 345 async fn removal_confirmation_rejects_every_tampered_authority_field() { 346 let core = AppCore::in_memory(RelayConfiguration::default()); 347 let public_key = crate::test_support::valid_test_public_key(7).expect("public key"); 348 let other_key = crate::test_support::valid_test_public_key(8).expect("other key"); 349 let identity = NostrIdentity::new( 350 NostrIdentityReference::derive(public_key).expect("identity"), 351 LocalKeyringBinding::new(public_key, SignerAvailability::Available), 352 None, 353 IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), 354 None, 355 ) 356 .expect("identity"); 357 core.apply_transition(StateTransition::BootstrapRegistry { 358 identities: vec![identity], 359 selected: Some(public_key), 360 }) 361 .expect("registry"); 362 363 let now = UnixTimestamp::from_seconds(2).expect("now"); 364 let mut wrong_key = core.issue_removal_token(public_key, now).expect("token"); 365 wrong_key.public_key = other_key; 366 assert!(core.consume_removal_token(wrong_key, now).is_err()); 367 368 let mut wrong_revision = core.issue_removal_token(public_key, now).expect("token"); 369 wrong_revision.revision = crate::SnapshotRevision::initial(); 370 assert!(core.consume_removal_token(wrong_revision, now).is_err()); 371 372 let mut wrong_expiry = core.issue_removal_token(public_key, now).expect("token"); 373 wrong_expiry.expires_at = UnixTimestamp::from_seconds(999).expect("expiry"); 374 assert!(core.consume_removal_token(wrong_expiry, now).is_err()); 375 376 let mut wrong_impact = core.issue_removal_token(public_key, now).expect("token"); 377 wrong_impact.impact = super::RemovalImpact { 378 deletes_local_credential: false, 379 signs_out: false, 380 }; 381 assert!(core.consume_removal_token(wrong_impact, now).is_err()); 382 } 383 }