state_machine.rs (20304B)
1 use harvestcircle_domain::{NostrIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage}; 2 3 use crate::{ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState}; 4 5 #[derive(Clone, Debug, Eq, PartialEq)] 6 pub enum StateTransition { 7 Bootstrap, 8 BootstrapRegistry { 9 identities: Vec<NostrIdentity>, 10 selected: Option<PublicKey>, 11 }, 12 Fatal(SafeError), 13 ReplaceRegistry { 14 identities: Vec<NostrIdentity>, 15 selected: Option<PublicKey>, 16 }, 17 ReplaceRegistryPreservingSession { 18 identities: Vec<NostrIdentity>, 19 selected: Option<PublicKey>, 20 }, 21 Select(PublicKey), 22 BeginActivation(PublicKey), 23 ActivationSucceeded(Box<ActiveIdentitySnapshot>), 24 ActivationFailed(SafeError), 25 UpdateActiveIdentity { 26 expected: PublicKey, 27 active_identity: Box<ActiveIdentitySnapshot>, 28 problem: Option<SafeError>, 29 }, 30 SignOut, 31 SetProblem(Option<SafeError>), 32 } 33 34 #[derive(Clone)] 35 struct PreviousSession { 36 session: SessionState, 37 active_identity: Option<ActiveIdentitySnapshot>, 38 } 39 40 pub struct StateMachine { 41 snapshot: AppSnapshot, 42 pending_activation: Option<(PublicKey, PreviousSession)>, 43 } 44 45 impl StateMachine { 46 #[must_use] 47 pub fn booting() -> Self { 48 Self { 49 snapshot: AppSnapshot::booting(), 50 pending_activation: None, 51 } 52 } 53 54 #[must_use] 55 pub const fn snapshot(&self) -> &AppSnapshot { 56 &self.snapshot 57 } 58 59 /// Applies one deterministic state transition and returns the new snapshot. 60 /// 61 /// # Errors 62 /// 63 /// Returns a safe application error when the transition violates identity, 64 /// revision, activation, or snapshot invariants. 65 pub fn apply( 66 &mut self, 67 transition: StateTransition, 68 relay_configuration: &RelayConfiguration, 69 ) -> Result<AppSnapshot, SafeError> { 70 let next_revision = self 71 .snapshot 72 .revision() 73 .next() 74 .ok_or_else(invalid_application_state)?; 75 76 let next = match transition { 77 StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?, 78 StateTransition::BootstrapRegistry { 79 identities, 80 selected, 81 } => { 82 self.bootstrap_registry(next_revision, relay_configuration, identities, selected)? 83 } 84 StateTransition::Fatal(error) => { 85 AppSnapshot::fatal(next_revision, relay_configuration.clone(), error) 86 } 87 StateTransition::ReplaceRegistry { 88 identities, 89 selected, 90 } => self.replace_registry(next_revision, identities, selected)?, 91 StateTransition::ReplaceRegistryPreservingSession { 92 identities, 93 selected, 94 } => self.replace_registry_preserving_session(next_revision, identities, selected)?, 95 StateTransition::Select(public_key) => self.select(next_revision, public_key)?, 96 StateTransition::BeginActivation(public_key) => { 97 self.begin_activation(next_revision, public_key)? 98 } 99 StateTransition::ActivationSucceeded(active_identity) => { 100 self.activation_succeeded(next_revision, *active_identity)? 101 } 102 StateTransition::ActivationFailed(problem) => { 103 self.activation_failed(next_revision, problem)? 104 } 105 StateTransition::UpdateActiveIdentity { 106 expected, 107 active_identity, 108 problem, 109 } => self.update_active_identity(next_revision, expected, *active_identity, problem)?, 110 StateTransition::SignOut => self.sign_out(next_revision)?, 111 StateTransition::SetProblem(problem) => self.copy_ready( 112 next_revision, 113 self.snapshot.selected_identity(), 114 self.snapshot.session(), 115 self.snapshot.active_identity().cloned(), 116 problem, 117 )?, 118 }; 119 self.snapshot = next.clone(); 120 Ok(next) 121 } 122 123 fn bootstrap( 124 &self, 125 revision: crate::SnapshotRevision, 126 relay_configuration: &RelayConfiguration, 127 ) -> Result<AppSnapshot, SafeError> { 128 if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { 129 return Ok(self.snapshot.clone()); 130 } 131 AppSnapshot::ready( 132 revision, 133 relay_configuration.clone(), 134 Vec::new(), 135 None, 136 SessionState::SignedOut, 137 None, 138 None, 139 ) 140 } 141 142 fn bootstrap_registry( 143 &self, 144 revision: crate::SnapshotRevision, 145 relay_configuration: &RelayConfiguration, 146 identities: Vec<NostrIdentity>, 147 selected: Option<PublicKey>, 148 ) -> Result<AppSnapshot, SafeError> { 149 if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { 150 return Ok(self.snapshot.clone()); 151 } 152 AppSnapshot::ready( 153 revision, 154 relay_configuration.clone(), 155 identities, 156 selected, 157 SessionState::SignedOut, 158 None, 159 None, 160 ) 161 } 162 163 fn replace_registry( 164 &mut self, 165 revision: crate::SnapshotRevision, 166 identities: Vec<NostrIdentity>, 167 selected: Option<PublicKey>, 168 ) -> Result<AppSnapshot, SafeError> { 169 self.pending_activation = None; 170 AppSnapshot::ready( 171 revision, 172 self.snapshot.relay_configuration().clone(), 173 identities, 174 selected, 175 SessionState::SignedOut, 176 None, 177 None, 178 ) 179 } 180 181 fn replace_registry_preserving_session( 182 &mut self, 183 revision: crate::SnapshotRevision, 184 identities: Vec<NostrIdentity>, 185 selected: Option<PublicKey>, 186 ) -> Result<AppSnapshot, SafeError> { 187 self.pending_activation = None; 188 AppSnapshot::ready( 189 revision, 190 self.snapshot.relay_configuration().clone(), 191 identities, 192 selected, 193 self.snapshot.session(), 194 self.snapshot.active_identity().cloned(), 195 None, 196 ) 197 } 198 199 fn select( 200 &self, 201 revision: crate::SnapshotRevision, 202 public_key: PublicKey, 203 ) -> Result<AppSnapshot, SafeError> { 204 self.require_identity(public_key)?; 205 self.copy_ready( 206 revision, 207 Some(public_key), 208 self.snapshot.session(), 209 self.snapshot.active_identity().cloned(), 210 None, 211 ) 212 } 213 214 fn begin_activation( 215 &mut self, 216 revision: crate::SnapshotRevision, 217 public_key: PublicKey, 218 ) -> Result<AppSnapshot, SafeError> { 219 self.require_identity(public_key)?; 220 if self.pending_activation.is_some() { 221 return Err(invalid_application_state()); 222 } 223 self.pending_activation = Some(( 224 public_key, 225 PreviousSession { 226 session: self.snapshot.session(), 227 active_identity: self.snapshot.active_identity().cloned(), 228 }, 229 )); 230 self.copy_ready( 231 revision, 232 self.snapshot.selected_identity(), 233 SessionState::Activating(public_key), 234 self.snapshot.active_identity().cloned(), 235 None, 236 ) 237 } 238 239 fn activation_succeeded( 240 &mut self, 241 revision: crate::SnapshotRevision, 242 active_identity: ActiveIdentitySnapshot, 243 ) -> Result<AppSnapshot, SafeError> { 244 let Some((target, _previous)) = self.pending_activation.as_ref() else { 245 return Err(invalid_application_state()); 246 }; 247 if active_identity.identity().public_key() != *target { 248 return Err(invalid_application_state()); 249 } 250 let target = *target; 251 let mut identities = self.snapshot.identities().to_vec(); 252 let registered = identities 253 .iter_mut() 254 .find(|identity| identity.public_key() == target) 255 .ok_or_else(identity_not_found)?; 256 *registered = active_identity.identity().clone(); 257 let next = AppSnapshot::ready( 258 revision, 259 self.snapshot.relay_configuration().clone(), 260 identities, 261 Some(target), 262 SessionState::Active, 263 Some(active_identity), 264 None, 265 )?; 266 self.pending_activation = None; 267 Ok(next) 268 } 269 270 fn activation_failed( 271 &mut self, 272 revision: crate::SnapshotRevision, 273 problem: SafeError, 274 ) -> Result<AppSnapshot, SafeError> { 275 let Some((_target, previous)) = self.pending_activation.take() else { 276 return Err(invalid_application_state()); 277 }; 278 self.copy_ready( 279 revision, 280 self.snapshot.selected_identity(), 281 previous.session, 282 previous.active_identity, 283 Some(problem), 284 ) 285 } 286 287 fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> { 288 self.pending_activation = None; 289 self.copy_ready( 290 revision, 291 self.snapshot.selected_identity(), 292 SessionState::SignedOut, 293 None, 294 None, 295 ) 296 } 297 298 fn update_active_identity( 299 &self, 300 revision: crate::SnapshotRevision, 301 expected: PublicKey, 302 active_identity: ActiveIdentitySnapshot, 303 problem: Option<SafeError>, 304 ) -> Result<AppSnapshot, SafeError> { 305 if !matches!(self.snapshot.session(), SessionState::Active) 306 || self 307 .snapshot 308 .active_identity() 309 .map(|active| active.identity().public_key()) 310 != Some(expected) 311 || active_identity.identity().public_key() != expected 312 { 313 return Err(invalid_application_state()); 314 } 315 self.copy_ready( 316 revision, 317 self.snapshot.selected_identity(), 318 SessionState::Active, 319 Some(active_identity), 320 problem, 321 ) 322 } 323 324 fn require_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { 325 if self 326 .snapshot 327 .identities() 328 .iter() 329 .any(|identity| identity.public_key() == public_key) 330 { 331 Ok(()) 332 } else { 333 Err(identity_not_found()) 334 } 335 } 336 337 fn copy_ready( 338 &self, 339 revision: crate::SnapshotRevision, 340 selected_identity: Option<PublicKey>, 341 session: SessionState, 342 active_identity: Option<ActiveIdentitySnapshot>, 343 recoverable_problem: Option<SafeError>, 344 ) -> Result<AppSnapshot, SafeError> { 345 AppSnapshot::ready( 346 revision, 347 self.snapshot.relay_configuration().clone(), 348 self.snapshot.identities().to_vec(), 349 selected_identity, 350 session, 351 active_identity, 352 recoverable_problem, 353 ) 354 } 355 } 356 357 const fn invalid_application_state() -> SafeError { 358 SafeError::new( 359 SafeErrorCode::InvalidApplicationState, 360 SafeMessage::new("The application state is invalid."), 361 ) 362 } 363 364 const fn identity_not_found() -> SafeError { 365 SafeError::new( 366 SafeErrorCode::IdentityNotFound, 367 SafeMessage::new("The identity was not found."), 368 ) 369 } 370 371 #[cfg(test)] 372 mod tests { 373 use harvestcircle_domain::{ 374 IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, SafeError, 375 SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, 376 }; 377 378 use crate::{ 379 ActiveIdentitySnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, 380 SessionState, StateMachine, StateTransition, 381 }; 382 383 fn identity(key_byte: u8) -> NostrIdentity { 384 let public_key = 385 crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); 386 NostrIdentity::new( 387 NostrIdentityReference::derive(public_key).expect("identity"), 388 LocalKeyringBinding::new(public_key, SignerAvailability::Available), 389 None, 390 IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), 391 None, 392 ) 393 .expect("identity") 394 } 395 396 fn active(identity: NostrIdentity) -> ActiveIdentitySnapshot { 397 ActiveIdentitySnapshot::new( 398 identity, 399 RelayConnectionState::Disconnected, 400 ProfileLoadState::Empty, 401 None, 402 ) 403 } 404 405 #[test] 406 fn state_machine_command_trace_preserves_working_session_on_failed_replacement() { 407 let first = identity(1); 408 let second = identity(2); 409 let mut machine = StateMachine::booting(); 410 let relays = RelayConfiguration::default(); 411 let problem = SafeError::new( 412 SafeErrorCode::CredentialMissing, 413 SafeMessage::new("The identity credential is missing."), 414 ); 415 416 machine 417 .apply(StateTransition::Bootstrap, &relays) 418 .expect("bootstrap"); 419 machine 420 .apply( 421 StateTransition::ReplaceRegistry { 422 identities: vec![first.clone(), second.clone()], 423 selected: Some(first.public_key()), 424 }, 425 &relays, 426 ) 427 .expect("load registry"); 428 machine 429 .apply( 430 StateTransition::BeginActivation(first.public_key()), 431 &relays, 432 ) 433 .expect("begin first activation"); 434 machine 435 .apply( 436 StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), 437 &relays, 438 ) 439 .expect("activate first"); 440 machine 441 .apply(StateTransition::Select(second.public_key()), &relays) 442 .expect("select second"); 443 let pending = machine 444 .apply( 445 StateTransition::BeginActivation(second.public_key()), 446 &relays, 447 ) 448 .expect("begin replacement"); 449 let restored = machine 450 .apply(StateTransition::ActivationFailed(problem), &relays) 451 .expect("fail replacement"); 452 453 assert_eq!( 454 pending.session(), 455 SessionState::Activating(second.public_key()) 456 ); 457 assert_eq!( 458 pending 459 .active_identity() 460 .map(|value| value.identity().public_key()), 461 Some(first.public_key()) 462 ); 463 assert_eq!(restored.session(), SessionState::Active); 464 assert_eq!(restored.selected_identity(), Some(second.public_key())); 465 assert_eq!( 466 restored 467 .active_identity() 468 .map(|value| value.identity().public_key()), 469 Some(first.public_key()) 470 ); 471 assert_eq!(restored.recoverable_problem(), Some(problem)); 472 assert_eq!(restored.revision().value(), 7); 473 } 474 475 #[test] 476 fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() { 477 let identity = identity(1); 478 let mut machine = StateMachine::booting(); 479 let relays = RelayConfiguration::default(); 480 machine 481 .apply(StateTransition::Bootstrap, &relays) 482 .expect("bootstrap"); 483 machine 484 .apply( 485 StateTransition::ReplaceRegistry { 486 identities: vec![identity.clone()], 487 selected: Some(identity.public_key()), 488 }, 489 &relays, 490 ) 491 .expect("load registry"); 492 493 let error = machine 494 .apply( 495 StateTransition::Select( 496 crate::test_support::valid_test_public_key(9).expect("valid public key"), 497 ), 498 &relays, 499 ) 500 .expect_err("missing identity"); 501 assert_eq!(error.code(), SafeErrorCode::IdentityNotFound); 502 503 machine 504 .apply( 505 StateTransition::BeginActivation(identity.public_key()), 506 &relays, 507 ) 508 .expect("begin activation"); 509 machine 510 .apply( 511 StateTransition::ActivationSucceeded(Box::new(active(identity.clone()))), 512 &relays, 513 ) 514 .expect("activate"); 515 let signed_out = machine 516 .apply(StateTransition::SignOut, &relays) 517 .expect("sign out"); 518 519 assert_eq!(signed_out.identities(), &[identity]); 520 assert_eq!(signed_out.session(), SessionState::SignedOut); 521 assert!(signed_out.active_identity().is_none()); 522 } 523 524 #[test] 525 fn activation_state_policy_rejects_every_stale_or_mismatched_transition() { 526 let first = identity(1); 527 let second = identity(2); 528 let relays = RelayConfiguration::default(); 529 let problem = SafeError::new( 530 SafeErrorCode::CredentialMissing, 531 SafeMessage::new("The identity credential is missing."), 532 ); 533 let mut machine = StateMachine::booting(); 534 machine 535 .apply( 536 StateTransition::BootstrapRegistry { 537 identities: vec![first.clone(), second.clone()], 538 selected: Some(first.public_key()), 539 }, 540 &relays, 541 ) 542 .expect("registry"); 543 let unchanged = machine 544 .apply( 545 StateTransition::BootstrapRegistry { 546 identities: Vec::new(), 547 selected: None, 548 }, 549 &relays, 550 ) 551 .expect("repeated bootstrap is idempotent"); 552 assert_eq!(unchanged.identities().len(), 2); 553 554 assert!( 555 machine 556 .apply( 557 StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), 558 &relays, 559 ) 560 .is_err() 561 ); 562 assert!( 563 machine 564 .apply(StateTransition::ActivationFailed(problem), &relays) 565 .is_err() 566 ); 567 machine 568 .apply( 569 StateTransition::BeginActivation(first.public_key()), 570 &relays, 571 ) 572 .expect("begin activation"); 573 assert!( 574 machine 575 .apply( 576 StateTransition::BeginActivation(second.public_key()), 577 &relays, 578 ) 579 .is_err() 580 ); 581 assert!( 582 machine 583 .apply( 584 StateTransition::ActivationSucceeded(Box::new(active(second.clone()))), 585 &relays, 586 ) 587 .is_err() 588 ); 589 machine 590 .apply( 591 StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), 592 &relays, 593 ) 594 .expect("activate first"); 595 596 for (expected, candidate) in [ 597 (second.public_key(), first.clone()), 598 (first.public_key(), second.clone()), 599 ] { 600 assert!( 601 machine 602 .apply( 603 StateTransition::UpdateActiveIdentity { 604 expected, 605 active_identity: Box::new(active(candidate)), 606 problem: None, 607 }, 608 &relays, 609 ) 610 .is_err() 611 ); 612 } 613 614 machine 615 .apply(StateTransition::SignOut, &relays) 616 .expect("sign out"); 617 assert!( 618 machine 619 .apply( 620 StateTransition::UpdateActiveIdentity { 621 expected: first.public_key(), 622 active_identity: Box::new(active(first)), 623 problem: None, 624 }, 625 &relays, 626 ) 627 .is_err() 628 ); 629 } 630 }