app

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

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 }