app

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

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 }