app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

snapshot.rs (15428B)


      1 use std::collections::HashSet;
      2 
      3 use harvestcircle_domain::{
      4     NostrIdentity, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, SafeMessage,
      5 };
      6 use radroots_transport_nostr::{RelayEndpoint, RelayProfile, RelayProfileKind, RelayUrlPolicy};
      7 
      8 pub const MAX_CONFIGURED_RELAYS: usize = 16;
      9 
     10 #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
     11 pub struct SnapshotRevision(u64);
     12 
     13 impl SnapshotRevision {
     14     #[must_use]
     15     pub const fn initial() -> Self {
     16         Self(0)
     17     }
     18 
     19     #[must_use]
     20     pub const fn from_value(value: u64) -> Self {
     21         Self(value)
     22     }
     23 
     24     #[must_use]
     25     pub const fn value(self) -> u64 {
     26         self.0
     27     }
     28 
     29     #[must_use]
     30     pub const fn next(self) -> Option<Self> {
     31         match self.0.checked_add(1) {
     32             Some(value) => Some(Self(value)),
     33             None => None,
     34         }
     35     }
     36 }
     37 
     38 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     39 pub enum AppLifecycle {
     40     Booting,
     41     Ready,
     42     Fatal(SafeError),
     43 }
     44 
     45 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     46 pub enum SessionState {
     47     SignedOut,
     48     Activating(PublicKey),
     49     Active,
     50     SigningOut,
     51     Failed(SafeError),
     52 }
     53 
     54 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     55 pub enum RelayConnectionState {
     56     Disconnected,
     57     Connecting,
     58     Connected,
     59     Degraded,
     60     Error(SafeError),
     61 }
     62 
     63 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     64 pub enum ProfileLoadState {
     65     Empty,
     66     Loading,
     67     Cached,
     68     Fresh,
     69     Error(SafeError),
     70 }
     71 
     72 #[derive(Clone, Default, Eq, PartialEq)]
     73 pub struct RelayConfiguration(Option<RelayProfile>);
     74 
     75 impl core::fmt::Debug for RelayConfiguration {
     76     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
     77         formatter
     78             .debug_struct("RelayConfiguration")
     79             .field("profile", &self.0.as_ref().map(RelayProfile::kind))
     80             .field("relay_count", &self.relays().len())
     81             .finish()
     82     }
     83 }
     84 
     85 impl RelayConfiguration {
     86     /// Creates a bounded, explicitly classified relay configuration.
     87     ///
     88     /// # Errors
     89     ///
     90     /// Returns a safe configuration error before runtime or network work when
     91     /// the relay count exceeds the HarvestCircle policy.
     92     pub fn new(relays: Vec<RelayEndpoint>) -> Result<Self, SafeError> {
     93         if relays.is_empty() {
     94             return Ok(Self::default());
     95         }
     96         if relays.len() > MAX_CONFIGURED_RELAYS {
     97             return Err(relay_limit_exceeded());
     98         }
     99         let has_local = relays
    100             .iter()
    101             .any(|relay| relay.policy() == RelayUrlPolicy::Local);
    102         let has_private = relays
    103             .iter()
    104             .any(|relay| relay.policy() == RelayUrlPolicy::PrivateNetwork);
    105         let has_public = relays
    106             .iter()
    107             .any(|relay| relay.policy() == RelayUrlPolicy::Public);
    108         let profile = match (has_local, has_private, has_public) {
    109             (true, false, false) => RelayProfileKind::Simulator,
    110             (false, false, true) => RelayProfileKind::Public,
    111             (false, true, _) => RelayProfileKind::Device,
    112             _ => return Err(relay_limit_exceeded()),
    113         };
    114         let profile =
    115             RelayProfile::explicit(profile, relays).map_err(|_| relay_limit_exceeded())?;
    116         Ok(Self(Some(profile)))
    117     }
    118 
    119     #[must_use]
    120     pub fn relays(&self) -> &[RelayEndpoint] {
    121         self.0.as_ref().map_or(&[], |profile| profile.endpoints())
    122     }
    123 
    124     #[must_use]
    125     pub const fn profile(&self) -> Option<&RelayProfile> {
    126         self.0.as_ref()
    127     }
    128 }
    129 
    130 const fn relay_limit_exceeded() -> SafeError {
    131     SafeError::new(
    132         SafeErrorCode::InvalidRelayConfiguration,
    133         SafeMessage::new("The Nostr relay configuration exceeds its limit."),
    134     )
    135 }
    136 
    137 #[derive(Clone, Debug, Eq, PartialEq)]
    138 pub struct ActiveIdentitySnapshot {
    139     identity: NostrIdentity,
    140     relay_state: RelayConnectionState,
    141     profile_state: ProfileLoadState,
    142     profile: Option<ProfileMetadata>,
    143 }
    144 
    145 impl ActiveIdentitySnapshot {
    146     #[must_use]
    147     pub const fn new(
    148         identity: NostrIdentity,
    149         relay_state: RelayConnectionState,
    150         profile_state: ProfileLoadState,
    151         profile: Option<ProfileMetadata>,
    152     ) -> Self {
    153         Self {
    154             identity,
    155             relay_state,
    156             profile_state,
    157             profile,
    158         }
    159     }
    160 
    161     #[must_use]
    162     pub const fn identity(&self) -> &NostrIdentity {
    163         &self.identity
    164     }
    165 
    166     #[must_use]
    167     pub const fn relay_state(&self) -> RelayConnectionState {
    168         self.relay_state
    169     }
    170 
    171     #[must_use]
    172     pub const fn profile_state(&self) -> ProfileLoadState {
    173         self.profile_state
    174     }
    175 
    176     #[must_use]
    177     pub const fn profile(&self) -> Option<&ProfileMetadata> {
    178         self.profile.as_ref()
    179     }
    180 }
    181 
    182 #[derive(Clone, Debug, Eq, PartialEq)]
    183 pub struct AppSnapshot {
    184     revision: SnapshotRevision,
    185     lifecycle: AppLifecycle,
    186     relay_configuration: RelayConfiguration,
    187     identities: Vec<NostrIdentity>,
    188     selected_identity: Option<PublicKey>,
    189     session: SessionState,
    190     active_identity: Option<ActiveIdentitySnapshot>,
    191     recoverable_problem: Option<SafeError>,
    192 }
    193 
    194 impl AppSnapshot {
    195     #[must_use]
    196     pub fn booting() -> Self {
    197         Self {
    198             revision: SnapshotRevision::initial(),
    199             lifecycle: AppLifecycle::Booting,
    200             relay_configuration: RelayConfiguration::default(),
    201             identities: Vec::new(),
    202             selected_identity: None,
    203             session: SessionState::SignedOut,
    204             active_identity: None,
    205             recoverable_problem: None,
    206         }
    207     }
    208 
    209     #[must_use]
    210     pub fn fatal(
    211         revision: SnapshotRevision,
    212         relay_configuration: RelayConfiguration,
    213         error: SafeError,
    214     ) -> Self {
    215         Self {
    216             revision,
    217             lifecycle: AppLifecycle::Fatal(error),
    218             relay_configuration,
    219             identities: Vec::new(),
    220             selected_identity: None,
    221             session: SessionState::SignedOut,
    222             active_identity: None,
    223             recoverable_problem: None,
    224         }
    225     }
    226 
    227     /// Constructs a ready immutable snapshot after validating state invariants.
    228     ///
    229     /// # Errors
    230     ///
    231     /// Returns a safe invalid-state error for duplicate identities, invalid
    232     /// selection, or inconsistent active-session state.
    233     pub fn ready(
    234         revision: SnapshotRevision,
    235         relay_configuration: RelayConfiguration,
    236         identities: Vec<NostrIdentity>,
    237         selected_identity: Option<PublicKey>,
    238         session: SessionState,
    239         active_identity: Option<ActiveIdentitySnapshot>,
    240         recoverable_problem: Option<SafeError>,
    241     ) -> Result<Self, SafeError> {
    242         validate_snapshot(
    243             &identities,
    244             selected_identity,
    245             session,
    246             active_identity.as_ref(),
    247         )?;
    248         Ok(Self {
    249             revision,
    250             lifecycle: AppLifecycle::Ready,
    251             relay_configuration,
    252             identities,
    253             selected_identity,
    254             session,
    255             active_identity,
    256             recoverable_problem,
    257         })
    258     }
    259 
    260     #[must_use]
    261     pub const fn revision(&self) -> SnapshotRevision {
    262         self.revision
    263     }
    264 
    265     #[must_use]
    266     pub const fn lifecycle(&self) -> AppLifecycle {
    267         self.lifecycle
    268     }
    269 
    270     #[must_use]
    271     pub const fn relay_configuration(&self) -> &RelayConfiguration {
    272         &self.relay_configuration
    273     }
    274 
    275     #[must_use]
    276     pub fn identities(&self) -> &[NostrIdentity] {
    277         &self.identities
    278     }
    279 
    280     #[must_use]
    281     pub const fn selected_identity(&self) -> Option<PublicKey> {
    282         self.selected_identity
    283     }
    284 
    285     #[must_use]
    286     pub const fn session(&self) -> SessionState {
    287         self.session
    288     }
    289 
    290     #[must_use]
    291     pub const fn active_identity(&self) -> Option<&ActiveIdentitySnapshot> {
    292         self.active_identity.as_ref()
    293     }
    294 
    295     #[must_use]
    296     pub const fn recoverable_problem(&self) -> Option<SafeError> {
    297         self.recoverable_problem
    298     }
    299 }
    300 
    301 fn validate_snapshot(
    302     identities: &[NostrIdentity],
    303     selected_identity: Option<PublicKey>,
    304     session: SessionState,
    305     active_identity: Option<&ActiveIdentitySnapshot>,
    306 ) -> Result<(), SafeError> {
    307     let unique_identities = identities
    308         .iter()
    309         .map(NostrIdentity::public_key)
    310         .collect::<HashSet<_>>();
    311     if unique_identities.len() != identities.len()
    312         || (identities.is_empty() != selected_identity.is_none())
    313         || selected_identity.is_some_and(|key| !unique_identities.contains(&key))
    314         || active_identity.is_some_and(|active| !identities.contains(active.identity()))
    315         || (matches!(session, SessionState::Active) && active_identity.is_none())
    316         || (matches!(session, SessionState::SignedOut) && active_identity.is_some())
    317     {
    318         return Err(invalid_snapshot());
    319     }
    320     Ok(())
    321 }
    322 
    323 const fn invalid_snapshot() -> SafeError {
    324     SafeError::new(
    325         SafeErrorCode::InvalidApplicationState,
    326         SafeMessage::new("The application state is invalid."),
    327     )
    328 }
    329 
    330 #[cfg(test)]
    331 mod tests {
    332     use harvestcircle_domain::{
    333         IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference,
    334         SafeErrorCode, SignerAvailability, UnixTimestamp,
    335     };
    336     use radroots_transport_nostr::{RelayAccess, RelayEndpoint, RelayUrlPolicy};
    337 
    338     use super::{
    339         ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
    340         RelayConnectionState, SessionState, SnapshotRevision,
    341     };
    342 
    343     fn identity(key_byte: u8) -> NostrIdentity {
    344         let public_key =
    345             crate::test_support::valid_test_public_key(key_byte).expect("valid public key");
    346         NostrIdentity::new(
    347             NostrIdentityReference::derive(public_key).expect("identity"),
    348             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    349             None,
    350             IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
    351             None,
    352         )
    353         .expect("identity")
    354     }
    355 
    356     #[test]
    357     fn snapshot_boots_empty_and_secret_free() {
    358         let snapshot = AppSnapshot::booting();
    359         let debug = format!("{snapshot:?}");
    360 
    361         assert_eq!(snapshot.revision(), SnapshotRevision::initial());
    362         assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting);
    363         assert_eq!(snapshot.session(), SessionState::SignedOut);
    364         assert!(snapshot.identities().is_empty());
    365         assert!(snapshot.selected_identity().is_none());
    366         assert!(snapshot.active_identity().is_none());
    367         assert!(snapshot.relay_configuration().relays().is_empty());
    368         assert!(snapshot.recoverable_problem().is_none());
    369         assert!(!debug.contains("nsec1"));
    370         assert!(!debug.contains(&"11".repeat(32)));
    371     }
    372 
    373     #[test]
    374     fn relay_configuration_rejects_excess_targets_before_runtime_work() {
    375         let relays = (0..=super::MAX_CONFIGURED_RELAYS)
    376             .map(|index| {
    377                 RelayEndpoint::new(
    378                     format!("wss://relay-{index}.example"),
    379                     RelayUrlPolicy::Public,
    380                     RelayAccess::ReadWrite,
    381                 )
    382                 .expect("relay")
    383             })
    384             .collect();
    385         let error = RelayConfiguration::new(relays).expect_err("relay limit");
    386         assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
    387     }
    388 
    389     #[test]
    390     fn revision_helper_is_monotonic_and_checked() {
    391         assert_eq!(
    392             SnapshotRevision::initial()
    393                 .next()
    394                 .map(SnapshotRevision::value),
    395             Some(1)
    396         );
    397         assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None);
    398     }
    399 
    400     #[test]
    401     fn ready_snapshot_requires_valid_selection_and_active_session() {
    402         let first = identity(1);
    403         let second = identity(2);
    404         let active = ActiveIdentitySnapshot::new(
    405             second.clone(),
    406             RelayConnectionState::Disconnected,
    407             ProfileLoadState::Empty,
    408             None,
    409         );
    410         let valid = AppSnapshot::ready(
    411             SnapshotRevision::from_value(1),
    412             RelayConfiguration::default(),
    413             vec![first.clone(), second.clone()],
    414             Some(first.public_key()),
    415             SessionState::Active,
    416             Some(active),
    417             None,
    418         )
    419         .expect("valid ready snapshot");
    420 
    421         assert_eq!(valid.lifecycle(), AppLifecycle::Ready);
    422         assert_eq!(valid.selected_identity(), Some(first.public_key()));
    423         assert_eq!(
    424             valid
    425                 .active_identity()
    426                 .map(|value| value.identity().public_key()),
    427             Some(second.public_key())
    428         );
    429 
    430         assert!(
    431             AppSnapshot::ready(
    432                 SnapshotRevision::initial(),
    433                 RelayConfiguration::default(),
    434                 vec![first.clone(), first],
    435                 Some(second.public_key()),
    436                 SessionState::SignedOut,
    437                 None,
    438                 None,
    439             )
    440             .is_err()
    441         );
    442         assert!(
    443             AppSnapshot::ready(
    444                 SnapshotRevision::initial(),
    445                 RelayConfiguration::default(),
    446                 vec![second.clone()],
    447                 Some(second.public_key()),
    448                 SessionState::Active,
    449                 None,
    450                 None,
    451             )
    452             .is_err()
    453         );
    454 
    455         let missing = identity(3);
    456         for result in [
    457             AppSnapshot::ready(
    458                 SnapshotRevision::initial(),
    459                 RelayConfiguration::default(),
    460                 Vec::new(),
    461                 Some(missing.public_key()),
    462                 SessionState::SignedOut,
    463                 None,
    464                 None,
    465             ),
    466             AppSnapshot::ready(
    467                 SnapshotRevision::initial(),
    468                 RelayConfiguration::default(),
    469                 vec![second.clone()],
    470                 None,
    471                 SessionState::SignedOut,
    472                 None,
    473                 None,
    474             ),
    475             AppSnapshot::ready(
    476                 SnapshotRevision::initial(),
    477                 RelayConfiguration::default(),
    478                 vec![second.clone()],
    479                 Some(missing.public_key()),
    480                 SessionState::SignedOut,
    481                 None,
    482                 None,
    483             ),
    484             AppSnapshot::ready(
    485                 SnapshotRevision::initial(),
    486                 RelayConfiguration::default(),
    487                 vec![second.clone()],
    488                 Some(second.public_key()),
    489                 SessionState::SignedOut,
    490                 Some(ActiveIdentitySnapshot::new(
    491                     missing,
    492                     RelayConnectionState::Disconnected,
    493                     ProfileLoadState::Empty,
    494                     None,
    495                 )),
    496                 None,
    497             ),
    498             AppSnapshot::ready(
    499                 SnapshotRevision::initial(),
    500                 RelayConfiguration::default(),
    501                 vec![second.clone()],
    502                 Some(second.public_key()),
    503                 SessionState::SignedOut,
    504                 Some(ActiveIdentitySnapshot::new(
    505                     second,
    506                     RelayConnectionState::Disconnected,
    507                     ProfileLoadState::Empty,
    508                     None,
    509                 )),
    510                 None,
    511             ),
    512         ] {
    513             assert!(result.is_err());
    514         }
    515     }
    516 }