app

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

identities.rs (89138B)


      1 use std::sync::{Mutex, MutexGuard};
      2 
      3 use crate::{
      4     AppCore, AppStateRepository, BoxFuture, Clock, DurableIdentityOperation, DurableOperationKind,
      5     DurableOperationPhase, DurableOperationRepository, DurableOperationStart, DurableRequestId,
      6     DurableTerminalOutcome, IdentityRepository, OperationPriorState, RemovalConfirmationToken,
      7     SecretStore, StagedGeneratedKey, StateTransition,
      8 };
      9 #[cfg(test)]
     10 use crate::{
     11     IdentityOperationKind, IdentityOperationPhase, OperationDiagnostic, OperationId,
     12     OperationJournal, PendingIdentityOperation,
     13 };
     14 use harvestcircle_domain::{
     15     IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, Nsec, PublicKey,
     16     SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability,
     17 };
     18 
     19 pub struct GenerateIdentityReceipt {
     20     identity: NostrIdentity,
     21     generated_nsec: Nsec,
     22 }
     23 
     24 #[derive(Clone, Debug, Eq, PartialEq)]
     25 pub struct ImportIdentityReceipt {
     26     identity: NostrIdentity,
     27 }
     28 
     29 impl ImportIdentityReceipt {
     30     #[must_use]
     31     pub const fn identity(&self) -> &NostrIdentity {
     32         &self.identity
     33     }
     34 }
     35 
     36 impl GenerateIdentityReceipt {
     37     #[must_use]
     38     pub const fn identity(&self) -> &NostrIdentity {
     39         &self.identity
     40     }
     41 
     42     #[must_use]
     43     pub const fn generated_nsec(&self) -> &Nsec {
     44         &self.generated_nsec
     45     }
     46 }
     47 
     48 impl AppCore {
     49     /// Commits a staged generated key only after its recovery acknowledgement.
     50     ///
     51     /// # Errors
     52     ///
     53     /// Returns a safe conflict, keyring, persistence, or recovery error.
     54     #[allow(clippy::too_many_arguments)]
     55     pub async fn commit_staged_generated_key(
     56         &self,
     57         request_id: &DurableRequestId,
     58         staged: StagedGeneratedKey,
     59         identities: &(impl IdentityRepository + ?Sized),
     60         app_state: &(impl AppStateRepository + ?Sized),
     61         secrets: &(impl SecretStore + ?Sized),
     62         operations: &(impl DurableOperationRepository + ?Sized),
     63         clock: &(impl Clock + ?Sized),
     64     ) -> Result<ImportIdentityReceipt, SafeError> {
     65         let expected_revision = staged.expected_revision();
     66         self.require_revision(expected_revision)?;
     67         let (identity, secret) = staged.into_commit_parts();
     68         let identity = self
     69             .persist_identity_durable(
     70                 request_id,
     71                 DurableOperationKind::Create,
     72                 expected_revision,
     73                 &identity,
     74                 secret,
     75                 None,
     76                 identities,
     77                 app_state,
     78                 secrets,
     79                 operations,
     80                 clock,
     81             )
     82             .await?;
     83         Ok(ImportIdentityReceipt { identity })
     84     }
     85 
     86     /// Generates and commits one identity under a durable caller request.
     87     ///
     88     /// # Errors
     89     ///
     90     /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport
     91     /// replaces this transitional generated-secret receipt in the custody phase.
     92     #[allow(clippy::too_many_arguments)]
     93     pub async fn generate_identity_durable(
     94         &self,
     95         request_id: &DurableRequestId,
     96         expected_revision: u64,
     97         identities: &(impl IdentityRepository + ?Sized),
     98         app_state: &(impl AppStateRepository + ?Sized),
     99         secrets: &(impl SecretStore + ?Sized),
    100         operations: &(impl DurableOperationRepository + ?Sized),
    101         clock: &(impl Clock + ?Sized),
    102     ) -> Result<GenerateIdentityReceipt, SafeError> {
    103         self.require_revision(expected_revision)?;
    104         let generated = self.key_material().generate()?;
    105         let (public_key, npub, secret, nsec) = generated.into_parts();
    106         let identity = NostrIdentity::new(
    107             NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?,
    108             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    109             None,
    110             IdentityCreatedAt::new(clock.now()),
    111             None,
    112         )?;
    113         let identity = self
    114             .persist_identity_durable(
    115                 request_id,
    116                 DurableOperationKind::Create,
    117                 expected_revision,
    118                 &identity,
    119                 secret,
    120                 None,
    121                 identities,
    122                 app_state,
    123                 secrets,
    124                 operations,
    125                 clock,
    126             )
    127             .await?;
    128         Ok(GenerateIdentityReceipt {
    129             identity,
    130             generated_nsec: nsec,
    131         })
    132     }
    133 
    134     /// Imports or explicitly repairs one local identity under a durable caller request.
    135     ///
    136     /// # Errors
    137     ///
    138     /// Returns a safe conflict, validation, keyring, persistence, or state error.
    139     #[allow(clippy::too_many_arguments)]
    140     pub async fn import_secret_key_durable(
    141         &self,
    142         request_id: &DurableRequestId,
    143         expected_revision: u64,
    144         input: SecretKeyInput,
    145         identities: &(impl IdentityRepository + ?Sized),
    146         app_state: &(impl AppStateRepository + ?Sized),
    147         secrets: &(impl SecretStore + ?Sized),
    148         operations: &(impl DurableOperationRepository + ?Sized),
    149         clock: &(impl Clock + ?Sized),
    150     ) -> Result<ImportIdentityReceipt, SafeError> {
    151         if let Some(existing) = operations.load_durable_operation(request_id).await? {
    152             if !matches!(
    153                 existing.kind(),
    154                 DurableOperationKind::Import | DurableOperationKind::Repair
    155             ) {
    156                 return Err(operation_conflict());
    157             }
    158             require_completed_identity_operation(&existing)?;
    159             let imported = self.key_material().import(input)?;
    160             let (public_key, _, secret) = imported.into_parts();
    161             verify_completed_identity_replay(
    162                 &existing,
    163                 request_id,
    164                 existing.kind(),
    165                 expected_revision,
    166                 public_key,
    167                 secret,
    168                 secrets,
    169             )
    170             .await?;
    171             return identities
    172                 .find_identity(public_key)
    173                 .await?
    174                 .map(|identity| ImportIdentityReceipt { identity })
    175                 .ok_or_else(recovery_required);
    176         }
    177         self.require_revision(expected_revision)?;
    178         let imported = self.key_material().import(input)?;
    179         let (public_key, npub, secret) = imported.into_parts();
    180         let previous = identities.find_identity(public_key).await?;
    181         if let Some(existing) = &previous
    182             && (local_keyring_binding(existing)?.availability()
    183                 != SignerAvailability::CredentialMissing
    184                 || secrets.contains(public_key).await?)
    185         {
    186             return Err(identity_exists());
    187         }
    188         if previous.is_none() && secrets.contains(public_key).await? {
    189             return Err(identity_exists());
    190         }
    191         let identity = if let Some(existing) = &previous {
    192             existing
    193                 .with_local_keyring_availability(SignerAvailability::Available)
    194                 .ok_or_else(recovery_required)?
    195         } else {
    196             NostrIdentity::new(
    197                 NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?,
    198                 LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    199                 None,
    200                 IdentityCreatedAt::new(clock.now()),
    201                 None,
    202             )?
    203         };
    204         let kind = if previous.is_some() {
    205             DurableOperationKind::Repair
    206         } else {
    207             DurableOperationKind::Import
    208         };
    209         let identity = self
    210             .persist_identity_durable(
    211                 request_id,
    212                 kind,
    213                 expected_revision,
    214                 &identity,
    215                 secret,
    216                 previous.as_ref(),
    217                 identities,
    218                 app_state,
    219                 secrets,
    220                 operations,
    221                 clock,
    222             )
    223             .await?;
    224         Ok(ImportIdentityReceipt { identity })
    225     }
    226 
    227     fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> {
    228         if self.snapshot().revision().value() != expected_revision {
    229             return Err(operation_conflict());
    230         }
    231         Ok(())
    232     }
    233 
    234     #[allow(clippy::too_many_arguments)]
    235     async fn persist_identity_durable(
    236         &self,
    237         request_id: &DurableRequestId,
    238         kind: DurableOperationKind,
    239         expected_revision: u64,
    240         identity: &NostrIdentity,
    241         secret: SecretKeyInput,
    242         previous: Option<&NostrIdentity>,
    243         identities: &(impl IdentityRepository + ?Sized),
    244         app_state: &(impl AppStateRepository + ?Sized),
    245         secrets: &(impl SecretStore + ?Sized),
    246         operations: &(impl DurableOperationRepository + ?Sized),
    247         clock: &(impl Clock + ?Sized),
    248     ) -> Result<NostrIdentity, SafeError> {
    249         let prior_availability = previous
    250             .map(local_keyring_binding)
    251             .transpose()?
    252             .map(LocalKeyringBinding::availability);
    253         let prior = OperationPriorState::new(
    254             app_state.load_selected_identity().await?,
    255             prior_availability,
    256         );
    257         match operations
    258             .begin_durable_operation(
    259                 request_id,
    260                 kind,
    261                 identity.public_key(),
    262                 Some(expected_revision),
    263                 prior,
    264                 clock.now(),
    265             )
    266             .await?
    267         {
    268             DurableOperationStart::Started(_) => {}
    269             DurableOperationStart::Existing(operation) => {
    270                 verify_completed_identity_replay(
    271                     &operation,
    272                     request_id,
    273                     kind,
    274                     expected_revision,
    275                     identity.public_key(),
    276                     secret,
    277                     secrets,
    278                 )
    279                 .await?;
    280                 return identities
    281                     .find_identity(operation.identity())
    282                     .await?
    283                     .ok_or_else(recovery_required);
    284             }
    285         }
    286         secrets
    287             .put(request_id, identity.public_key(), secret)
    288             .await?;
    289         operations
    290             .advance_durable_operation(
    291                 request_id,
    292                 DurableOperationPhase::IntentRecorded,
    293                 DurableOperationPhase::CredentialWritten,
    294                 clock.now(),
    295                 None,
    296             )
    297             .await?;
    298         if previous.is_some() {
    299             identities.update_identity(identity).await?;
    300         } else {
    301             identities.insert_identity(identity).await?;
    302         }
    303         operations
    304             .advance_durable_operation(
    305                 request_id,
    306                 DurableOperationPhase::CredentialWritten,
    307                 DurableOperationPhase::MetadataCommitted,
    308                 clock.now(),
    309                 None,
    310             )
    311             .await?;
    312         app_state
    313             .save_selected_identity(Some(identity.public_key()))
    314             .await?;
    315         operations
    316             .advance_durable_operation(
    317                 request_id,
    318                 DurableOperationPhase::MetadataCommitted,
    319                 DurableOperationPhase::SelectionCommitted,
    320                 clock.now(),
    321                 None,
    322             )
    323             .await?;
    324         let snapshot = self.apply_transition(StateTransition::ReplaceRegistry {
    325             identities: identities.list_identities().await?,
    326             selected: Some(identity.public_key()),
    327         })?;
    328         operations
    329             .finalize_durable_operation(
    330                 request_id,
    331                 DurableOperationPhase::SelectionCommitted,
    332                 DurableTerminalOutcome::Completed,
    333                 Some(snapshot.revision().value()),
    334                 clock.now(),
    335             )
    336             .await?;
    337         Ok(identity.clone())
    338     }
    339 
    340     /// Issues a single-use confirmation bound to the target and current revision.
    341     ///
    342     /// # Errors
    343     ///
    344     /// Returns a safe identity or application-state error.
    345     pub fn request_identity_removal(
    346         &self,
    347         public_key: PublicKey,
    348         clock: &(impl Clock + ?Sized),
    349     ) -> Result<RemovalConfirmationToken, SafeError> {
    350         self.issue_removal_token(public_key, clock.now())
    351     }
    352 
    353     pub fn cancel_identity_removal(&self, token: RemovalConfirmationToken) -> bool {
    354         self.cancel_removal_token(token)
    355     }
    356 
    357     /// Permanently removes a confirmed identity and selects a deterministic fallback.
    358     ///
    359     /// # Errors
    360     ///
    361     /// Returns a safe confirmation, credential, persistence, recovery, or state error.
    362     #[cfg(test)]
    363     pub async fn confirm_identity_removal(
    364         &self,
    365         token: RemovalConfirmationToken,
    366         identities: &(impl IdentityRepository + ?Sized),
    367         app_state: &(impl AppStateRepository + ?Sized),
    368         secrets: &(impl SecretStore + ?Sized),
    369         journal: &(impl OperationJournal + ?Sized),
    370         clock: &(impl Clock + ?Sized),
    371     ) -> Result<crate::AppSnapshot, SafeError> {
    372         let public_key = self.consume_removal_token(token, clock.now())?;
    373         let registry = identities.list_identities().await?;
    374         let index = registry
    375             .iter()
    376             .position(|identity| identity.public_key() == public_key)
    377             .ok_or_else(identity_not_found)?;
    378         let selected = if self.snapshot().selected_identity() == Some(public_key) {
    379             registry
    380                 .get(index + 1)
    381                 .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
    382                 .map(NostrIdentity::public_key)
    383         } else {
    384             self.snapshot().selected_identity()
    385         };
    386         let operation = journal
    387             .begin_operation(IdentityOperationKind::Remove, public_key, clock.now())
    388             .await?;
    389         let was_active = self
    390             .snapshot()
    391             .active_identity()
    392             .is_some_and(|active| active.identity().public_key() == public_key);
    393         if was_active {
    394             self.sign_out()?;
    395         }
    396         let identity = &registry[index];
    397         let local_keyring = local_keyring_binding(identity)?;
    398         let request_id = DurableRequestId::new_v7();
    399         match secrets.delete(&request_id, public_key).await {
    400             Ok(()) => {}
    401             Err(error)
    402                 if error.code() == SafeErrorCode::CredentialMissing
    403                     && local_keyring.availability() == SignerAvailability::CredentialMissing => {}
    404             Err(error) => return Err(error),
    405         }
    406         journal
    407             .update_operation(
    408                 operation,
    409                 IdentityOperationPhase::CredentialDeleted,
    410                 clock.now(),
    411                 None,
    412             )
    413             .await?;
    414         identities.remove_identity(public_key).await?;
    415         app_state.save_selected_identity(selected).await?;
    416         journal
    417             .update_operation(
    418                 operation,
    419                 IdentityOperationPhase::MetadataDeleted,
    420                 clock.now(),
    421                 None,
    422             )
    423             .await?;
    424         journal.finalize_operation(operation).await?;
    425         self.apply_transition(StateTransition::ReplaceRegistryPreservingSession {
    426             identities: identities.list_identities().await?,
    427             selected,
    428         })
    429     }
    430 
    431     /// Confirms and executes an expiring removal plan as a durable request.
    432     ///
    433     /// # Errors
    434     ///
    435     /// Returns a safe expiry, conflict, credential, persistence, or recovery error.
    436     #[allow(clippy::too_many_arguments)]
    437     pub async fn confirm_identity_removal_durable(
    438         &self,
    439         request_id: &DurableRequestId,
    440         token: RemovalConfirmationToken,
    441         identities: &(impl IdentityRepository + ?Sized),
    442         app_state: &(impl AppStateRepository + ?Sized),
    443         secrets: &(impl SecretStore + ?Sized),
    444         operations: &(impl DurableOperationRepository + ?Sized),
    445         clock: &(impl Clock + ?Sized),
    446     ) -> Result<crate::AppSnapshot, SafeError> {
    447         let expected_revision = token.revision().value();
    448         let public_key = self.consume_removal_token(token, clock.now())?;
    449         self.require_revision(expected_revision)?;
    450         let registry = identities.list_identities().await?;
    451         let index = registry
    452             .iter()
    453             .position(|identity| identity.public_key() == public_key)
    454             .ok_or_else(identity_not_found)?;
    455         let selected = if self.snapshot().selected_identity() == Some(public_key) {
    456             registry
    457                 .get(index + 1)
    458                 .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
    459                 .map(NostrIdentity::public_key)
    460         } else {
    461             self.snapshot().selected_identity()
    462         };
    463         let identity = &registry[index];
    464         let local_keyring = local_keyring_binding(identity)?;
    465         match operations
    466             .begin_durable_operation(
    467                 request_id,
    468                 DurableOperationKind::Remove,
    469                 public_key,
    470                 Some(expected_revision),
    471                 OperationPriorState::new(selected, Some(local_keyring.availability())),
    472                 clock.now(),
    473             )
    474             .await?
    475         {
    476             DurableOperationStart::Started(_) => {}
    477             DurableOperationStart::Existing(operation) => {
    478                 return if operation
    479                     .terminal()
    480                     .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
    481                 {
    482                     Ok(self.snapshot())
    483                 } else {
    484                     Err(recovery_required())
    485                 };
    486             }
    487         }
    488         if self
    489             .snapshot()
    490             .active_identity()
    491             .is_some_and(|active| active.identity().public_key() == public_key)
    492         {
    493             self.sign_out()?;
    494         }
    495         match secrets.delete(request_id, public_key).await {
    496             Ok(()) => {}
    497             Err(error)
    498                 if error.code() == SafeErrorCode::CredentialMissing
    499                     && local_keyring.availability() == SignerAvailability::CredentialMissing => {}
    500             Err(error) => return Err(error),
    501         }
    502         operations
    503             .advance_durable_operation(
    504                 request_id,
    505                 DurableOperationPhase::IntentRecorded,
    506                 DurableOperationPhase::CredentialDeleted,
    507                 clock.now(),
    508                 None,
    509             )
    510             .await?;
    511         identities.remove_identity(public_key).await?;
    512         operations
    513             .advance_durable_operation(
    514                 request_id,
    515                 DurableOperationPhase::CredentialDeleted,
    516                 DurableOperationPhase::MetadataDeleted,
    517                 clock.now(),
    518                 None,
    519             )
    520             .await?;
    521         app_state.save_selected_identity(selected).await?;
    522         operations
    523             .advance_durable_operation(
    524                 request_id,
    525                 DurableOperationPhase::MetadataDeleted,
    526                 DurableOperationPhase::SelectionCommitted,
    527                 clock.now(),
    528                 None,
    529             )
    530             .await?;
    531         let snapshot =
    532             self.apply_transition(StateTransition::ReplaceRegistryPreservingSession {
    533                 identities: identities.list_identities().await?,
    534                 selected,
    535             })?;
    536         operations
    537             .finalize_durable_operation(
    538                 request_id,
    539                 DurableOperationPhase::SelectionCommitted,
    540                 DurableTerminalOutcome::Completed,
    541                 Some(snapshot.revision().value()),
    542                 clock.now(),
    543             )
    544             .await?;
    545         Ok(snapshot)
    546     }
    547 
    548     /// Persists and publishes a saved identity selection without activating it.
    549     ///
    550     /// # Errors
    551     ///
    552     /// Returns a safe identity, persistence, or application-state error.
    553     pub async fn select_identity(
    554         &self,
    555         public_key: PublicKey,
    556         identities: &(impl IdentityRepository + ?Sized),
    557         app_state: &(impl AppStateRepository + ?Sized),
    558     ) -> Result<crate::AppSnapshot, SafeError> {
    559         if identities.find_identity(public_key).await?.is_none() {
    560             return Err(identity_not_found());
    561         }
    562         app_state.save_selected_identity(Some(public_key)).await?;
    563         self.apply_transition(StateTransition::Select(public_key))
    564     }
    565 
    566     /// Generates, stores, and selects one local Nostr identity without activating it.
    567     ///
    568     /// # Errors
    569     ///
    570     /// Returns a safe key, credential, persistence, or application-state error.
    571     #[cfg(test)]
    572     pub async fn generate_identity(
    573         &self,
    574         identities: &(impl IdentityRepository + ?Sized),
    575         app_state: &(impl AppStateRepository + ?Sized),
    576         secrets: &(impl SecretStore + ?Sized),
    577         journal: &(impl OperationJournal + ?Sized),
    578         clock: &(impl Clock + ?Sized),
    579     ) -> Result<GenerateIdentityReceipt, SafeError> {
    580         let generated = self.key_material().generate()?;
    581         let (public_key, npub, secret, nsec) = generated.into_parts();
    582         let identity = NostrIdentity::new(
    583             NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?,
    584             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    585             None,
    586             IdentityCreatedAt::new(clock.now()),
    587             None,
    588         )?;
    589         Self::persist_identity_transaction(
    590             IdentityOperationKind::Add,
    591             &identity,
    592             secret,
    593             None,
    594             identities,
    595             app_state,
    596             secrets,
    597             journal,
    598             clock,
    599         )
    600         .await?;
    601         let registry = identities.list_identities().await?;
    602         self.apply_transition(StateTransition::ReplaceRegistry {
    603             identities: registry,
    604             selected: Some(public_key),
    605         })?;
    606         Ok(GenerateIdentityReceipt {
    607             identity,
    608             generated_nsec: nsec,
    609         })
    610     }
    611 
    612     /// Imports, stores, and selects one local Nostr identity without activating it.
    613     ///
    614     /// # Errors
    615     ///
    616     /// Returns a safe key, credential, persistence, or application-state error.
    617     #[cfg(test)]
    618     pub async fn import_secret_key(
    619         &self,
    620         input: SecretKeyInput,
    621         identities: &(impl IdentityRepository + ?Sized),
    622         app_state: &(impl AppStateRepository + ?Sized),
    623         secrets: &(impl SecretStore + ?Sized),
    624         journal: &(impl OperationJournal + ?Sized),
    625         clock: &(impl Clock + ?Sized),
    626     ) -> Result<ImportIdentityReceipt, SafeError> {
    627         let imported = self.key_material().import(input)?;
    628         let (public_key, npub, secret) = imported.into_parts();
    629         if let Some(existing) = identities.find_identity(public_key).await? {
    630             if local_keyring_binding(&existing)?.availability()
    631                 != SignerAvailability::CredentialMissing
    632                 || secrets.contains(public_key).await?
    633             {
    634                 return Err(identity_exists());
    635             }
    636             let repaired = existing
    637                 .with_local_keyring_availability(SignerAvailability::Available)
    638                 .ok_or_else(recovery_required)?;
    639             Self::persist_identity_transaction(
    640                 IdentityOperationKind::Import,
    641                 &repaired,
    642                 secret,
    643                 Some(&existing),
    644                 identities,
    645                 app_state,
    646                 secrets,
    647                 journal,
    648                 clock,
    649             )
    650             .await?;
    651             self.apply_transition(StateTransition::ReplaceRegistry {
    652                 identities: identities.list_identities().await?,
    653                 selected: Some(public_key),
    654             })?;
    655             return Ok(ImportIdentityReceipt { identity: repaired });
    656         }
    657         if secrets.contains(public_key).await? {
    658             return Err(identity_exists());
    659         }
    660         let identity = NostrIdentity::new(
    661             NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?,
    662             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    663             None,
    664             IdentityCreatedAt::new(clock.now()),
    665             None,
    666         )?;
    667         Self::persist_identity_transaction(
    668             IdentityOperationKind::Import,
    669             &identity,
    670             secret,
    671             None,
    672             identities,
    673             app_state,
    674             secrets,
    675             journal,
    676             clock,
    677         )
    678         .await?;
    679         self.apply_transition(StateTransition::ReplaceRegistry {
    680             identities: identities.list_identities().await?,
    681             selected: Some(public_key),
    682         })?;
    683         Ok(ImportIdentityReceipt { identity })
    684     }
    685 
    686     #[cfg(test)]
    687     #[allow(clippy::too_many_arguments)]
    688     async fn persist_identity_transaction(
    689         kind: IdentityOperationKind,
    690         identity: &NostrIdentity,
    691         secret: SecretKeyInput,
    692         previous: Option<&NostrIdentity>,
    693         identities: &(impl IdentityRepository + ?Sized),
    694         app_state: &(impl AppStateRepository + ?Sized),
    695         secrets: &(impl SecretStore + ?Sized),
    696         journal: &(impl OperationJournal + ?Sized),
    697         clock: &(impl Clock + ?Sized),
    698     ) -> Result<(), SafeError> {
    699         let public_key = identity.public_key();
    700         let previous_selection = app_state.load_selected_identity().await?;
    701         let operation = journal
    702             .begin_operation(kind, public_key, clock.now())
    703             .await?;
    704         let request_id = DurableRequestId::new_v7();
    705         if let Err(error) = secrets.put(&request_id, public_key, secret).await {
    706             let _ = journal.finalize_operation(operation).await;
    707             return Err(error);
    708         }
    709         if let Err(error) = journal
    710             .update_operation(
    711                 operation,
    712                 IdentityOperationPhase::CredentialWritten,
    713                 clock.now(),
    714                 None,
    715             )
    716             .await
    717         {
    718             return compensate_identity_write(
    719                 operation,
    720                 public_key,
    721                 error,
    722                 None,
    723                 previous_selection,
    724                 identities,
    725                 app_state,
    726                 secrets,
    727                 journal,
    728                 clock,
    729             )
    730             .await;
    731         }
    732         let metadata_result = if previous.is_some() {
    733             identities.update_identity(identity).await
    734         } else {
    735             identities.insert_identity(identity).await
    736         };
    737         if let Err(error) = metadata_result {
    738             return compensate_identity_write(
    739                 operation,
    740                 public_key,
    741                 error,
    742                 previous,
    743                 previous_selection,
    744                 identities,
    745                 app_state,
    746                 secrets,
    747                 journal,
    748                 clock,
    749             )
    750             .await;
    751         }
    752         if let Err(error) = app_state.save_selected_identity(Some(public_key)).await {
    753             return compensate_identity_write(
    754                 operation,
    755                 public_key,
    756                 error,
    757                 previous,
    758                 previous_selection,
    759                 identities,
    760                 app_state,
    761                 secrets,
    762                 journal,
    763                 clock,
    764             )
    765             .await;
    766         }
    767         journal
    768             .update_operation(
    769                 operation,
    770                 IdentityOperationPhase::MetadataCommitted,
    771                 clock.now(),
    772                 None,
    773             )
    774             .await?;
    775         journal.finalize_operation(operation).await
    776     }
    777 }
    778 
    779 #[cfg(test)]
    780 #[allow(clippy::too_many_arguments)]
    781 async fn compensate_identity_write(
    782     operation: OperationId,
    783     public_key: PublicKey,
    784     original_error: SafeError,
    785     previous: Option<&NostrIdentity>,
    786     previous_selection: Option<PublicKey>,
    787     identities: &(impl IdentityRepository + ?Sized),
    788     app_state: &(impl AppStateRepository + ?Sized),
    789     secrets: &(impl SecretStore + ?Sized),
    790     journal: &(impl OperationJournal + ?Sized),
    791     clock: &(impl Clock + ?Sized),
    792 ) -> Result<(), SafeError> {
    793     let metadata_rollback = if let Some(previous) = previous {
    794         identities.update_identity(previous).await
    795     } else {
    796         identities.remove_identity(public_key).await
    797     };
    798     let selection_rollback = app_state.save_selected_identity(previous_selection).await;
    799     let request_id = DurableRequestId::new_v7();
    800     let credential_rollback = secrets.delete(&request_id, public_key).await;
    801     if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() {
    802         let _ = journal
    803             .update_operation(
    804                 operation,
    805                 IdentityOperationPhase::CompensationPending,
    806                 clock.now(),
    807                 Some(OperationDiagnostic::CompensationFailed),
    808             )
    809             .await;
    810         return Err(recovery_required());
    811     }
    812     let _ = journal.finalize_operation(operation).await;
    813     Err(original_error)
    814 }
    815 
    816 #[cfg(test)]
    817 #[derive(Default)]
    818 pub struct InMemoryOperationJournal {
    819     state: Mutex<InMemoryJournalState>,
    820 }
    821 
    822 #[cfg(test)]
    823 #[derive(Default)]
    824 struct InMemoryJournalState {
    825     next_id: u64,
    826     pending: Vec<PendingIdentityOperation>,
    827 }
    828 
    829 #[cfg(test)]
    830 impl OperationJournal for InMemoryOperationJournal {
    831     fn begin_operation<'a>(
    832         &'a self,
    833         kind: IdentityOperationKind,
    834         subject: PublicKey,
    835         updated_at: harvestcircle_domain::UnixTimestamp,
    836     ) -> BoxFuture<'a, Result<OperationId, SafeError>> {
    837         Box::pin(async move {
    838             let mut state = self.state.lock().map_err(|_| recovery_required())?;
    839             state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?;
    840             let id = OperationId::from_raw(state.next_id);
    841             state.pending.push(PendingIdentityOperation::new(
    842                 id,
    843                 kind,
    844                 subject,
    845                 IdentityOperationPhase::IntentRecorded,
    846                 updated_at,
    847                 None,
    848             ));
    849             Ok(id)
    850         })
    851     }
    852 
    853     fn update_operation<'a>(
    854         &'a self,
    855         id: OperationId,
    856         phase: IdentityOperationPhase,
    857         updated_at: harvestcircle_domain::UnixTimestamp,
    858         diagnostic: Option<OperationDiagnostic>,
    859     ) -> BoxFuture<'a, Result<(), SafeError>> {
    860         Box::pin(async move {
    861             let mut state = self.state.lock().map_err(|_| recovery_required())?;
    862             let operation = state
    863                 .pending
    864                 .iter_mut()
    865                 .find(|operation| operation.id() == id)
    866                 .ok_or_else(recovery_required)?;
    867             *operation = PendingIdentityOperation::new(
    868                 id,
    869                 operation.kind(),
    870                 operation.subject(),
    871                 phase,
    872                 updated_at,
    873                 diagnostic,
    874             );
    875             Ok(())
    876         })
    877     }
    878 
    879     fn list_pending_operations(
    880         &self,
    881     ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, SafeError>> {
    882         Box::pin(async move {
    883             Ok(self
    884                 .state
    885                 .lock()
    886                 .map_err(|_| recovery_required())?
    887                 .pending
    888                 .clone())
    889         })
    890     }
    891 
    892     fn finalize_operation(&self, id: OperationId) -> BoxFuture<'_, Result<(), SafeError>> {
    893         Box::pin(async move {
    894             self.state
    895                 .lock()
    896                 .map_err(|_| recovery_required())?
    897                 .pending
    898                 .retain(|operation| operation.id() != id);
    899             Ok(())
    900         })
    901     }
    902 }
    903 
    904 #[derive(Default)]
    905 pub struct InMemoryIdentityRepository {
    906     state: Mutex<InMemoryIdentityState>,
    907 }
    908 
    909 #[derive(Default)]
    910 struct InMemoryIdentityState {
    911     identities: Vec<NostrIdentity>,
    912     selected: Option<PublicKey>,
    913 }
    914 
    915 impl InMemoryIdentityRepository {
    916     fn state(&self) -> Result<MutexGuard<'_, InMemoryIdentityState>, SafeError> {
    917         self.state.lock().map_err(|_| recovery_required())
    918     }
    919 }
    920 
    921 impl IdentityRepository for InMemoryIdentityRepository {
    922     fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> {
    923         Box::pin(async move { Ok(self.state()?.identities.clone()) })
    924     }
    925 
    926     fn find_identity(
    927         &self,
    928         public_key: PublicKey,
    929     ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> {
    930         Box::pin(async move {
    931             Ok(self
    932                 .state()?
    933                 .identities
    934                 .iter()
    935                 .find(|identity| identity.public_key() == public_key)
    936                 .cloned())
    937         })
    938     }
    939 
    940     fn insert_identity<'a>(
    941         &'a self,
    942         identity: &'a NostrIdentity,
    943     ) -> BoxFuture<'a, Result<(), SafeError>> {
    944         Box::pin(async move {
    945             let mut state = self.state()?;
    946             if state
    947                 .identities
    948                 .iter()
    949                 .any(|saved| saved.public_key() == identity.public_key())
    950             {
    951                 return Err(identity_exists());
    952             }
    953             state.identities.push(identity.clone());
    954             state
    955                 .identities
    956                 .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key()));
    957             Ok(())
    958         })
    959     }
    960 
    961     fn update_identity<'a>(
    962         &'a self,
    963         identity: &'a NostrIdentity,
    964     ) -> BoxFuture<'a, Result<(), SafeError>> {
    965         Box::pin(async move {
    966             let mut state = self.state()?;
    967             let saved = state
    968                 .identities
    969                 .iter_mut()
    970                 .find(|saved| saved.public_key() == identity.public_key())
    971                 .ok_or_else(identity_not_found)?;
    972             *saved = identity.clone();
    973             Ok(())
    974         })
    975     }
    976 
    977     fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
    978         Box::pin(async move {
    979             let mut state = self.state()?;
    980             state
    981                 .identities
    982                 .retain(|identity| identity.public_key() != public_key);
    983             if state.selected == Some(public_key) {
    984                 state.selected = None;
    985             }
    986             Ok(())
    987         })
    988     }
    989 }
    990 
    991 impl AppStateRepository for InMemoryIdentityRepository {
    992     fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> {
    993         Box::pin(async move { Ok(self.state()?.selected) })
    994     }
    995 
    996     fn save_selected_identity(
    997         &self,
    998         public_key: Option<PublicKey>,
    999     ) -> BoxFuture<'_, Result<(), SafeError>> {
   1000         Box::pin(async move {
   1001             let mut state = self.state()?;
   1002             if public_key.is_some_and(|key| {
   1003                 !state
   1004                     .identities
   1005                     .iter()
   1006                     .any(|identity| identity.public_key() == key)
   1007             }) {
   1008                 return Err(identity_not_found());
   1009             }
   1010             state.selected = public_key;
   1011             Ok(())
   1012         })
   1013     }
   1014 }
   1015 
   1016 fn require_completed_identity_operation(
   1017     operation: &DurableIdentityOperation,
   1018 ) -> Result<(), SafeError> {
   1019     if operation.phase() != DurableOperationPhase::Finalized
   1020         || !operation
   1021             .terminal()
   1022             .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
   1023     {
   1024         return Err(recovery_required());
   1025     }
   1026     Ok(())
   1027 }
   1028 
   1029 async fn verify_completed_identity_replay(
   1030     operation: &DurableIdentityOperation,
   1031     request_id: &DurableRequestId,
   1032     kind: DurableOperationKind,
   1033     expected_revision: u64,
   1034     public_key: PublicKey,
   1035     secret: SecretKeyInput,
   1036     secrets: &(impl SecretStore + ?Sized),
   1037 ) -> Result<(), SafeError> {
   1038     if operation.request_id() != request_id
   1039         || operation.kind() != kind
   1040         || operation.identity() != public_key
   1041         || operation.expected_revision() != Some(expected_revision)
   1042     {
   1043         return Err(operation_conflict());
   1044     }
   1045     require_completed_identity_operation(operation)?;
   1046     let receipt = operation.terminal().ok_or_else(recovery_required)?;
   1047     if receipt.request_id() != request_id || receipt.identity() != public_key {
   1048         return Err(operation_conflict());
   1049     }
   1050     secrets.verify(request_id, public_key, secret).await
   1051 }
   1052 
   1053 const fn identity_exists() -> SafeError {
   1054     SafeError::new(
   1055         SafeErrorCode::IdentityAlreadyExists,
   1056         SafeMessage::new("The Nostr identity is already saved."),
   1057     )
   1058 }
   1059 
   1060 const fn identity_not_found() -> SafeError {
   1061     SafeError::new(
   1062         SafeErrorCode::IdentityNotFound,
   1063         SafeMessage::new("The identity was not found."),
   1064     )
   1065 }
   1066 
   1067 fn local_keyring_binding(identity: &NostrIdentity) -> Result<LocalKeyringBinding, SafeError> {
   1068     identity
   1069         .signer_binding()
   1070         .as_local_keyring()
   1071         .ok_or_else(recovery_required)
   1072 }
   1073 
   1074 const fn recovery_required() -> SafeError {
   1075     SafeError::new(
   1076         SafeErrorCode::PendingOperationRecoveryRequired,
   1077         SafeMessage::new("Identity recovery is required before this operation can continue."),
   1078     )
   1079 }
   1080 
   1081 const fn operation_conflict() -> SafeError {
   1082     SafeError::new(
   1083         SafeErrorCode::InvalidApplicationState,
   1084         SafeMessage::new("The identity operation conflicts with the current application state."),
   1085     )
   1086 }
   1087 
   1088 #[cfg(test)]
   1089 mod tests {
   1090     use std::sync::atomic::{AtomicBool, Ordering};
   1091 
   1092     use harvestcircle_domain::{
   1093         IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey,
   1094         SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp,
   1095     };
   1096 
   1097     use super::InMemoryIdentityRepository;
   1098     use crate::{
   1099         AppCore, AppStateRepository, BoxFuture, Clock, DurableIdentityOperation,
   1100         DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, DurableRequestId,
   1101         DurableTerminalOutcome, FailureSecretStore, IdentityOperationPhase, IdentityRepository,
   1102         InMemoryOperationJournal, InMemorySecretStore, OperationJournal, OperationPriorState,
   1103         ProfileRefreshStatus, ProfileRepository, RelayConfiguration, SecretStore,
   1104         SecretStoreOperation, SessionState, StateTransition,
   1105         recovery::tests::{TestDurableRepository, operation as durable_operation},
   1106     };
   1107 
   1108     struct FixedClock;
   1109 
   1110     impl Clock for FixedClock {
   1111         fn now(&self) -> UnixTimestamp {
   1112             UnixTimestamp::from_seconds(10).expect("time")
   1113         }
   1114     }
   1115 
   1116     struct LateClock;
   1117 
   1118     impl Clock for LateClock {
   1119         fn now(&self) -> UnixTimestamp {
   1120             UnixTimestamp::from_seconds(311).expect("time")
   1121         }
   1122     }
   1123 
   1124     struct EmptyProfiles;
   1125 
   1126     impl ProfileRepository for EmptyProfiles {
   1127         fn load_profile(
   1128             &self,
   1129             _public_key: PublicKey,
   1130         ) -> BoxFuture<'_, Result<Option<crate::CachedProfile>, SafeError>> {
   1131             Box::pin(async { Ok(None) })
   1132         }
   1133 
   1134         fn save_profile<'a>(
   1135             &'a self,
   1136             _profile: &'a crate::CachedProfile,
   1137         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1138             Box::pin(async { Ok(()) })
   1139         }
   1140 
   1141         fn record_refresh_status<'a>(
   1142             &'a self,
   1143             _public_key: PublicKey,
   1144             _refreshed_at: UnixTimestamp,
   1145             _status: ProfileRefreshStatus,
   1146         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1147             Box::pin(async { Ok(()) })
   1148         }
   1149 
   1150         fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
   1151             Box::pin(async { Ok(()) })
   1152         }
   1153     }
   1154 
   1155     #[derive(Default)]
   1156     struct FailingUpdateJournal(InMemoryOperationJournal);
   1157 
   1158     impl OperationJournal for FailingUpdateJournal {
   1159         fn begin_operation<'a>(
   1160             &'a self,
   1161             kind: crate::IdentityOperationKind,
   1162             subject: PublicKey,
   1163             updated_at: UnixTimestamp,
   1164         ) -> BoxFuture<'a, Result<crate::OperationId, SafeError>> {
   1165             self.0.begin_operation(kind, subject, updated_at)
   1166         }
   1167 
   1168         fn update_operation<'a>(
   1169             &'a self,
   1170             _id: crate::OperationId,
   1171             _phase: IdentityOperationPhase,
   1172             _updated_at: UnixTimestamp,
   1173             _diagnostic: Option<crate::OperationDiagnostic>,
   1174         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1175             Box::pin(async {
   1176                 Err(SafeError::new(
   1177                     SafeErrorCode::StorageUnavailable,
   1178                     SafeMessage::new("The test journal is unavailable."),
   1179                 ))
   1180             })
   1181         }
   1182 
   1183         fn list_pending_operations(
   1184             &self,
   1185         ) -> BoxFuture<'_, Result<Vec<crate::PendingIdentityOperation>, SafeError>> {
   1186             self.0.list_pending_operations()
   1187         }
   1188 
   1189         fn finalize_operation(
   1190             &self,
   1191             id: crate::OperationId,
   1192         ) -> BoxFuture<'_, Result<(), SafeError>> {
   1193             self.0.finalize_operation(id)
   1194         }
   1195     }
   1196 
   1197     #[derive(Default)]
   1198     struct FailingInsertRepository {
   1199         inner: InMemoryIdentityRepository,
   1200     }
   1201 
   1202     #[derive(Default)]
   1203     struct FailingSelectionRepository {
   1204         inner: InMemoryIdentityRepository,
   1205         fail_next_selection: AtomicBool,
   1206     }
   1207 
   1208     impl IdentityRepository for FailingSelectionRepository {
   1209         fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> {
   1210             self.inner.list_identities()
   1211         }
   1212 
   1213         fn find_identity(
   1214             &self,
   1215             public_key: PublicKey,
   1216         ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> {
   1217             self.inner.find_identity(public_key)
   1218         }
   1219 
   1220         fn insert_identity<'a>(
   1221             &'a self,
   1222             identity: &'a NostrIdentity,
   1223         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1224             self.inner.insert_identity(identity)
   1225         }
   1226 
   1227         fn update_identity<'a>(
   1228             &'a self,
   1229             identity: &'a NostrIdentity,
   1230         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1231             self.inner.update_identity(identity)
   1232         }
   1233 
   1234         fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
   1235             self.inner.remove_identity(public_key)
   1236         }
   1237     }
   1238 
   1239     impl AppStateRepository for FailingSelectionRepository {
   1240         fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> {
   1241             self.inner.load_selected_identity()
   1242         }
   1243 
   1244         fn save_selected_identity(
   1245             &self,
   1246             public_key: Option<PublicKey>,
   1247         ) -> BoxFuture<'_, Result<(), SafeError>> {
   1248             if self.fail_next_selection.swap(false, Ordering::SeqCst) {
   1249                 return Box::pin(async {
   1250                     Err(SafeError::new(
   1251                         SafeErrorCode::StorageUnavailable,
   1252                         SafeMessage::new("The test selection repository is unavailable."),
   1253                     ))
   1254                 });
   1255             }
   1256             self.inner.save_selected_identity(public_key)
   1257         }
   1258     }
   1259 
   1260     impl IdentityRepository for FailingInsertRepository {
   1261         fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> {
   1262             self.inner.list_identities()
   1263         }
   1264 
   1265         fn find_identity(
   1266             &self,
   1267             public_key: PublicKey,
   1268         ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> {
   1269             self.inner.find_identity(public_key)
   1270         }
   1271 
   1272         fn insert_identity<'a>(
   1273             &'a self,
   1274             _identity: &'a NostrIdentity,
   1275         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1276             Box::pin(async {
   1277                 Err(SafeError::new(
   1278                     SafeErrorCode::StorageUnavailable,
   1279                     SafeMessage::new("The test identity repository is unavailable."),
   1280                 ))
   1281             })
   1282         }
   1283 
   1284         fn update_identity<'a>(
   1285             &'a self,
   1286             identity: &'a NostrIdentity,
   1287         ) -> BoxFuture<'a, Result<(), SafeError>> {
   1288             self.inner.update_identity(identity)
   1289         }
   1290 
   1291         fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
   1292             self.inner.remove_identity(public_key)
   1293         }
   1294     }
   1295 
   1296     impl AppStateRepository for FailingInsertRepository {
   1297         fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> {
   1298             self.inner.load_selected_identity()
   1299         }
   1300 
   1301         fn save_selected_identity(
   1302             &self,
   1303             public_key: Option<PublicKey>,
   1304         ) -> BoxFuture<'_, Result<(), SafeError>> {
   1305             self.inner.save_selected_identity(public_key)
   1306         }
   1307     }
   1308 
   1309     #[tokio::test]
   1310     async fn generate_identity_stores_selects_and_returns_one_time_nsec_without_activation() {
   1311         let core = AppCore::in_memory(RelayConfiguration::default());
   1312         let identities = InMemoryIdentityRepository::default();
   1313         let secrets = InMemorySecretStore::default();
   1314         let journal = InMemoryOperationJournal::default();
   1315         core.bootstrap().expect("bootstrap");
   1316 
   1317         let receipt = core
   1318             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1319             .await
   1320             .expect("generate");
   1321         let public_key = receipt.identity().public_key();
   1322         assert_eq!(public_key.to_hex().len(), 64);
   1323         assert!(secrets.contains(public_key).await.expect("credential"));
   1324         assert_eq!(
   1325             identities
   1326                 .load_selected_identity()
   1327                 .await
   1328                 .expect("selection"),
   1329             Some(public_key)
   1330         );
   1331         assert_eq!(core.snapshot().selected_identity(), Some(public_key));
   1332         assert_eq!(core.snapshot().session(), SessionState::SignedOut);
   1333         assert!(core.snapshot().active_identity().is_none());
   1334         assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63);
   1335         assert!(!format!("{:?}", core.snapshot()).contains("nsec1"));
   1336     }
   1337 
   1338     #[tokio::test]
   1339     async fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() {
   1340         for input in [
   1341             "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
   1342             "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
   1343         ] {
   1344             let core = AppCore::in_memory(RelayConfiguration::default());
   1345             let identities = InMemoryIdentityRepository::default();
   1346             let secrets = InMemorySecretStore::default();
   1347             let journal = InMemoryOperationJournal::default();
   1348             core.bootstrap().expect("bootstrap");
   1349             let receipt = core
   1350                 .import_secret_key(
   1351                     SecretKeyInput::parse(input.to_owned()).expect("input"),
   1352                     &identities,
   1353                     &identities,
   1354                     &secrets,
   1355                     &journal,
   1356                     &FixedClock,
   1357                 )
   1358                 .await
   1359                 .expect("import");
   1360             let public_key = receipt.identity().public_key();
   1361             assert!(secrets.contains(public_key).await.expect("credential"));
   1362             assert_eq!(core.snapshot().selected_identity(), Some(public_key));
   1363             assert_eq!(core.snapshot().session(), SessionState::SignedOut);
   1364             assert!(!format!("{:?}", core.snapshot()).contains(input));
   1365         }
   1366     }
   1367 
   1368     #[tokio::test]
   1369     async fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() {
   1370         let core = AppCore::in_memory(RelayConfiguration::default());
   1371         let identities = InMemoryIdentityRepository::default();
   1372         let secrets = InMemorySecretStore::default();
   1373         let journal = InMemoryOperationJournal::default();
   1374         core.bootstrap().expect("bootstrap");
   1375         let input = SecretKeyInput::parse(
   1376             "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
   1377         )
   1378         .expect("domain shape");
   1379         let error = core
   1380             .import_secret_key(
   1381                 input,
   1382                 &identities,
   1383                 &identities,
   1384                 &secrets,
   1385                 &journal,
   1386                 &FixedClock,
   1387             )
   1388             .await
   1389             .expect_err("invalid import");
   1390         assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
   1391         assert!(core.snapshot().identities().is_empty());
   1392     }
   1393 
   1394     #[tokio::test]
   1395     async fn duplicate_import_preserves_existing_credential_and_snapshot() {
   1396         let core = AppCore::in_memory(RelayConfiguration::default());
   1397         let identities = InMemoryIdentityRepository::default();
   1398         let secrets = InMemorySecretStore::default();
   1399         let journal = InMemoryOperationJournal::default();
   1400         core.bootstrap().expect("bootstrap");
   1401         let import = || {
   1402             SecretKeyInput::parse(
   1403                 "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
   1404             )
   1405             .expect("input")
   1406         };
   1407         core.import_secret_key(
   1408             import(),
   1409             &identities,
   1410             &identities,
   1411             &secrets,
   1412             &journal,
   1413             &FixedClock,
   1414         )
   1415         .await
   1416         .expect("first import");
   1417         let before = core.snapshot();
   1418         let error = core
   1419             .import_secret_key(
   1420                 import(),
   1421                 &identities,
   1422                 &identities,
   1423                 &secrets,
   1424                 &journal,
   1425                 &FixedClock,
   1426             )
   1427             .await
   1428             .expect_err("duplicate");
   1429         assert_eq!(error.code(), SafeErrorCode::IdentityAlreadyExists);
   1430         assert_eq!(core.snapshot(), before);
   1431         assert_eq!(core.snapshot().identities().len(), 1);
   1432     }
   1433 
   1434     #[tokio::test]
   1435     async fn duplicate_import_repairs_only_explicit_missing_credential_identity() {
   1436         let core = AppCore::in_memory(RelayConfiguration::default());
   1437         let identities = InMemoryIdentityRepository::default();
   1438         let secrets = InMemorySecretStore::default();
   1439         let journal = InMemoryOperationJournal::default();
   1440         core.bootstrap().expect("bootstrap");
   1441         let input = || {
   1442             SecretKeyInput::parse(
   1443                 "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
   1444             )
   1445             .expect("input")
   1446         };
   1447         let imported = core.key_material().import(input()).expect("derive");
   1448         let (public_key, npub, _) = imported.into_parts();
   1449         let missing = NostrIdentity::new(
   1450             NostrIdentityReference::verify(public_key, npub.as_str().to_owned()).expect("identity"),
   1451             LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing),
   1452             None,
   1453             IdentityCreatedAt::new(FixedClock.now()),
   1454             None,
   1455         )
   1456         .expect("missing identity");
   1457         identities
   1458             .insert_identity(&missing)
   1459             .await
   1460             .expect("missing metadata");
   1461         identities
   1462             .save_selected_identity(Some(public_key))
   1463             .await
   1464             .expect("selection");
   1465         core.apply_transition(StateTransition::ReplaceRegistry {
   1466             identities: vec![missing],
   1467             selected: Some(public_key),
   1468         })
   1469         .expect("registry");
   1470 
   1471         let receipt = core
   1472             .import_secret_key(
   1473                 input(),
   1474                 &identities,
   1475                 &identities,
   1476                 &secrets,
   1477                 &journal,
   1478                 &FixedClock,
   1479             )
   1480             .await
   1481             .expect("repair");
   1482         assert_eq!(
   1483             receipt
   1484                 .identity()
   1485                 .signer_binding()
   1486                 .as_local_keyring()
   1487                 .expect("local keyring")
   1488                 .availability(),
   1489             SignerAvailability::Available
   1490         );
   1491         assert!(secrets.contains(public_key).await.expect("credential"));
   1492         assert_eq!(core.snapshot().identities().len(), 1);
   1493     }
   1494 
   1495     #[tokio::test]
   1496     async fn identity_transaction_publishes_nothing_when_credential_write_fails() {
   1497         let core = AppCore::in_memory(RelayConfiguration::default());
   1498         let identities = InMemoryIdentityRepository::default();
   1499         let secrets = FailureSecretStore::default();
   1500         let journal = InMemoryOperationJournal::default();
   1501         core.bootstrap().expect("bootstrap");
   1502         secrets.fail_next(SecretStoreOperation::Put);
   1503 
   1504         let error = core
   1505             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1506             .await
   1507             .err()
   1508             .expect("credential failure");
   1509         assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
   1510         assert!(core.snapshot().identities().is_empty());
   1511         assert!(
   1512             journal
   1513                 .list_pending_operations()
   1514                 .await
   1515                 .expect("journal")
   1516                 .is_empty()
   1517         );
   1518     }
   1519 
   1520     #[tokio::test]
   1521     async fn identity_transaction_removes_written_credential_when_metadata_fails() {
   1522         let core = AppCore::in_memory(RelayConfiguration::default());
   1523         let identities = FailingInsertRepository::default();
   1524         let secrets = FailureSecretStore::default();
   1525         let journal = InMemoryOperationJournal::default();
   1526         core.bootstrap().expect("bootstrap");
   1527 
   1528         let error = core
   1529             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1530             .await
   1531             .err()
   1532             .expect("metadata failure");
   1533         assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
   1534         let calls = secrets.calls();
   1535         assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
   1536         assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
   1537         assert_eq!(calls[0].public_key(), calls[1].public_key());
   1538         assert!(core.snapshot().identities().is_empty());
   1539         assert!(
   1540             journal
   1541                 .list_pending_operations()
   1542                 .await
   1543                 .expect("journal")
   1544                 .is_empty()
   1545         );
   1546     }
   1547 
   1548     #[tokio::test]
   1549     async fn identity_transaction_rolls_back_metadata_and_credential_when_selection_fails() {
   1550         let core = AppCore::in_memory(RelayConfiguration::default());
   1551         let identities = FailingSelectionRepository::default();
   1552         let secrets = FailureSecretStore::default();
   1553         let journal = InMemoryOperationJournal::default();
   1554         core.bootstrap().expect("bootstrap");
   1555         identities.fail_next_selection.store(true, Ordering::SeqCst);
   1556 
   1557         let error = core
   1558             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1559             .await
   1560             .err()
   1561             .expect("selection failure");
   1562 
   1563         assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
   1564         assert!(
   1565             identities
   1566                 .list_identities()
   1567                 .await
   1568                 .expect("identities")
   1569                 .is_empty()
   1570         );
   1571         assert_eq!(
   1572             identities
   1573                 .load_selected_identity()
   1574                 .await
   1575                 .expect("selection"),
   1576             None
   1577         );
   1578         let calls = secrets.calls();
   1579         assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
   1580         assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
   1581         assert_eq!(calls[0].public_key(), calls[1].public_key());
   1582         assert!(core.snapshot().identities().is_empty());
   1583         assert!(
   1584             journal
   1585                 .list_pending_operations()
   1586                 .await
   1587                 .expect("journal")
   1588                 .is_empty()
   1589         );
   1590     }
   1591 
   1592     #[tokio::test]
   1593     async fn identity_transaction_retains_non_secret_journal_when_compensation_fails() {
   1594         let core = AppCore::in_memory(RelayConfiguration::default());
   1595         let identities = FailingInsertRepository::default();
   1596         let secrets = FailureSecretStore::default();
   1597         let journal = InMemoryOperationJournal::default();
   1598         core.bootstrap().expect("bootstrap");
   1599         secrets.fail_next(SecretStoreOperation::Delete);
   1600 
   1601         let error = core
   1602             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1603             .await
   1604             .err()
   1605             .expect("recovery required");
   1606         assert_eq!(
   1607             error.code(),
   1608             SafeErrorCode::PendingOperationRecoveryRequired
   1609         );
   1610         let pending = journal.list_pending_operations().await.expect("journal");
   1611         assert_eq!(pending.len(), 1);
   1612         assert_eq!(
   1613             pending[0].phase(),
   1614             IdentityOperationPhase::CompensationPending
   1615         );
   1616         assert!(!format!("{pending:?}").contains("nsec1"));
   1617         assert!(core.snapshot().identities().is_empty());
   1618     }
   1619 
   1620     #[tokio::test]
   1621     async fn select_identity_persists_existing_choice_without_activating() {
   1622         let core = AppCore::in_memory(RelayConfiguration::default());
   1623         let identities = InMemoryIdentityRepository::default();
   1624         let secrets = InMemorySecretStore::default();
   1625         let journal = InMemoryOperationJournal::default();
   1626         core.bootstrap().expect("bootstrap");
   1627         let first = core
   1628             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1629             .await
   1630             .expect("first")
   1631             .identity()
   1632             .public_key();
   1633         core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1634             .await
   1635             .expect("second");
   1636 
   1637         let selected = core
   1638             .select_identity(first, &identities, &identities)
   1639             .await
   1640             .expect("select first");
   1641         assert_eq!(selected.selected_identity(), Some(first));
   1642         assert_eq!(selected.session(), SessionState::SignedOut);
   1643         assert!(selected.active_identity().is_none());
   1644         assert_eq!(
   1645             identities.load_selected_identity().await.expect("saved"),
   1646             Some(first)
   1647         );
   1648         let missing = core
   1649             .select_identity(
   1650                 PublicKey::from_hex(
   1651                     "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
   1652                 )
   1653                 .expect("unknown public key"),
   1654                 &identities,
   1655                 &identities,
   1656             )
   1657             .await
   1658             .expect_err("missing identity");
   1659         assert_eq!(missing.code(), SafeErrorCode::IdentityNotFound);
   1660         assert_eq!(core.snapshot(), selected);
   1661     }
   1662 
   1663     #[tokio::test]
   1664     async fn remove_identity_requires_fresh_single_use_confirmation_and_selects_next_fallback() {
   1665         let core = AppCore::in_memory(RelayConfiguration::default());
   1666         let identities = InMemoryIdentityRepository::default();
   1667         let secrets = InMemorySecretStore::default();
   1668         let journal = InMemoryOperationJournal::default();
   1669         core.bootstrap().expect("bootstrap");
   1670         let first = core
   1671             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1672             .await
   1673             .expect("first")
   1674             .identity()
   1675             .public_key();
   1676         let second = core
   1677             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1678             .await
   1679             .expect("second")
   1680             .identity()
   1681             .public_key();
   1682         core.select_identity(first, &identities, &identities)
   1683             .await
   1684             .expect("select first");
   1685         let stale = core
   1686             .request_identity_removal(first, &FixedClock)
   1687             .expect("stale token");
   1688         core.select_identity(second, &identities, &identities)
   1689             .await
   1690             .expect("change revision");
   1691         let stale_error = core
   1692             .confirm_identity_removal(
   1693                 stale,
   1694                 &identities,
   1695                 &identities,
   1696                 &secrets,
   1697                 &journal,
   1698                 &FixedClock,
   1699             )
   1700             .await
   1701             .expect_err("stale token");
   1702         assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState);
   1703         assert_eq!(core.snapshot().identities().len(), 2);
   1704 
   1705         core.select_identity(first, &identities, &identities)
   1706             .await
   1707             .expect("reselect first");
   1708         let token = core
   1709             .request_identity_removal(first, &FixedClock)
   1710             .expect("token");
   1711         let removed = core
   1712             .confirm_identity_removal(
   1713                 token,
   1714                 &identities,
   1715                 &identities,
   1716                 &secrets,
   1717                 &journal,
   1718                 &FixedClock,
   1719             )
   1720             .await
   1721             .expect("remove");
   1722         assert_eq!(removed.identities().len(), 1);
   1723         assert_eq!(removed.selected_identity(), Some(second));
   1724         assert!(!secrets.contains(first).await.expect("credential removed"));
   1725         assert_eq!(removed.session(), SessionState::SignedOut);
   1726     }
   1727 
   1728     #[tokio::test]
   1729     async fn removal_preflight_reports_impact_expires_and_can_be_cancelled() {
   1730         let core = AppCore::in_memory(RelayConfiguration::default());
   1731         let identities = InMemoryIdentityRepository::default();
   1732         let secrets = InMemorySecretStore::default();
   1733         let journal = InMemoryOperationJournal::default();
   1734         core.bootstrap().expect("bootstrap");
   1735         let identity = core
   1736             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   1737             .await
   1738             .expect("identity")
   1739             .identity()
   1740             .public_key();
   1741         let expired = core
   1742             .request_identity_removal(identity, &FixedClock)
   1743             .expect("plan");
   1744         assert!(expired.impact().deletes_local_credential());
   1745         assert!(!expired.impact().signs_out());
   1746         assert!(
   1747             core.confirm_identity_removal(
   1748                 expired,
   1749                 &identities,
   1750                 &identities,
   1751                 &secrets,
   1752                 &journal,
   1753                 &LateClock,
   1754             )
   1755             .await
   1756             .is_err()
   1757         );
   1758         let cancelled = core
   1759             .request_identity_removal(identity, &FixedClock)
   1760             .expect("replacement plan");
   1761         assert!(core.cancel_identity_removal(cancelled));
   1762         assert_eq!(core.snapshot().identities().len(), 1);
   1763     }
   1764 
   1765     #[tokio::test]
   1766     async fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() {
   1767         const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
   1768         let core = AppCore::in_memory(RelayConfiguration::default());
   1769         let identities = InMemoryIdentityRepository::default();
   1770         let secrets = InMemorySecretStore::default();
   1771         let journal = InMemoryOperationJournal::default();
   1772         core.bootstrap().expect("bootstrap");
   1773         let material = core
   1774             .key_material()
   1775             .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
   1776             .expect("key material");
   1777         let (public_key, _npub, secret) = material.into_parts();
   1778         secrets
   1779             .put(&DurableRequestId::new_v7(), public_key, secret)
   1780             .await
   1781             .expect("orphan credential");
   1782 
   1783         assert_eq!(
   1784             core.import_secret_key(
   1785                 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
   1786                 &identities,
   1787                 &identities,
   1788                 &secrets,
   1789                 &journal,
   1790                 &FixedClock,
   1791             )
   1792             .await
   1793             .expect_err("orphan credential must fail")
   1794             .code(),
   1795             SafeErrorCode::IdentityAlreadyExists
   1796         );
   1797 
   1798         let pending = durable_operation(
   1799             DurableOperationKind::Import,
   1800             DurableOperationPhase::IntentRecorded,
   1801             public_key,
   1802             None,
   1803         );
   1804         let request_id = pending.request_id().clone();
   1805         let operations = TestDurableRepository::new(pending);
   1806         assert_eq!(
   1807             core.import_secret_key_durable(
   1808                 &request_id,
   1809                 core.snapshot().revision().value(),
   1810                 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
   1811                 &identities,
   1812                 &identities,
   1813                 &secrets,
   1814                 &operations,
   1815                 &FixedClock,
   1816             )
   1817             .await
   1818             .expect_err("unfinished replay must require recovery")
   1819             .code(),
   1820             SafeErrorCode::PendingOperationRecoveryRequired
   1821         );
   1822     }
   1823 
   1824     const ADMISSION_SECRET: &str =
   1825         "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
   1826 
   1827     struct CompletedAdmissionFixture {
   1828         core: AppCore,
   1829         identities: InMemoryIdentityRepository,
   1830         secrets: FailureSecretStore,
   1831         operations: TestDurableRepository,
   1832         original: NostrIdentity,
   1833         candidate: NostrIdentity,
   1834         request: DurableRequestId,
   1835         expected_revision: u64,
   1836     }
   1837 
   1838     impl CompletedAdmissionFixture {
   1839         async fn new() -> Self {
   1840             let core = AppCore::in_memory(RelayConfiguration::default());
   1841             core.bootstrap().expect("bootstrap");
   1842             let identities = InMemoryIdentityRepository::default();
   1843             let secrets = FailureSecretStore::default();
   1844             let material = core
   1845                 .key_material()
   1846                 .import(SecretKeyInput::parse(ADMISSION_SECRET.to_owned()).expect("secret"))
   1847                 .expect("key material");
   1848             let (public_key, npub, secret) = material.into_parts();
   1849             let original = NostrIdentity::new(
   1850                 NostrIdentityReference::verify(public_key, npub.as_str().to_owned())
   1851                     .expect("reference"),
   1852                 LocalKeyringBinding::new(public_key, SignerAvailability::Available),
   1853                 None,
   1854                 IdentityCreatedAt::new(FixedClock.now()),
   1855                 None,
   1856             )
   1857             .expect("original identity");
   1858             let candidate = NostrIdentity::new(
   1859                 NostrIdentityReference::verify(public_key, npub.as_str().to_owned())
   1860                     .expect("reference"),
   1861                 LocalKeyringBinding::new(public_key, SignerAvailability::Available),
   1862                 None,
   1863                 IdentityCreatedAt::new(LateClock.now()),
   1864                 None,
   1865             )
   1866             .expect("candidate identity");
   1867             identities
   1868                 .insert_identity(&original)
   1869                 .await
   1870                 .expect("insert original identity");
   1871             identities
   1872                 .save_selected_identity(Some(public_key))
   1873                 .await
   1874                 .expect("selection");
   1875             let request = DurableRequestId::new_v7();
   1876             secrets
   1877                 .put(&request, public_key, secret)
   1878                 .await
   1879                 .expect("original custody");
   1880             let expected_revision = core.snapshot().revision().value();
   1881             let operation = DurableIdentityOperation::new(
   1882                 request.clone(),
   1883                 DurableOperationKind::Import,
   1884                 public_key,
   1885                 Some(expected_revision),
   1886                 DurableOperationPhase::Finalized,
   1887                 OperationPriorState::new(Some(public_key), None),
   1888                 FixedClock.now(),
   1889                 None,
   1890                 Some(DurableOperationReceipt::new(
   1891                     request.clone(),
   1892                     public_key,
   1893                     DurableTerminalOutcome::Completed,
   1894                     Some(expected_revision + 1),
   1895                     FixedClock.now(),
   1896                 )),
   1897             );
   1898             Self {
   1899                 core,
   1900                 identities,
   1901                 secrets,
   1902                 operations: TestDurableRepository::new(operation),
   1903                 original,
   1904                 candidate,
   1905                 request,
   1906                 expected_revision,
   1907             }
   1908         }
   1909 
   1910         fn canonical_secret(&self) -> SecretKeyInput {
   1911             self.core
   1912                 .key_material()
   1913                 .import(SecretKeyInput::parse(ADMISSION_SECRET.to_owned()).expect("secret"))
   1914                 .expect("key material")
   1915                 .into_parts()
   1916                 .2
   1917         }
   1918 
   1919         async fn attempt(
   1920             &self,
   1921             kind: DurableOperationKind,
   1922             expected_revision: u64,
   1923             secret: SecretKeyInput,
   1924         ) -> Result<NostrIdentity, SafeError> {
   1925             self.core
   1926                 .persist_identity_durable(
   1927                     &self.request,
   1928                     kind,
   1929                     expected_revision,
   1930                     &self.candidate,
   1931                     secret,
   1932                     None,
   1933                     &self.identities,
   1934                     &self.identities,
   1935                     &self.secrets,
   1936                     &self.operations,
   1937                     &FixedClock,
   1938                 )
   1939                 .await
   1940         }
   1941     }
   1942 
   1943     #[tokio::test]
   1944     async fn completed_admission_race_verifies_original_binding_and_identity_without_mutation() {
   1945         let fixture = CompletedAdmissionFixture::new().await;
   1946         let before_operation = fixture.operations.operation().clone();
   1947         let before_state = fixture.core.snapshot();
   1948         let before_identities = fixture
   1949             .identities
   1950             .list_identities()
   1951             .await
   1952             .expect("identities");
   1953         let before_selection = fixture
   1954             .identities
   1955             .load_selected_identity()
   1956             .await
   1957             .expect("selection");
   1958         let exact = fixture
   1959             .attempt(
   1960                 DurableOperationKind::Import,
   1961                 fixture.expected_revision,
   1962                 fixture.canonical_secret(),
   1963             )
   1964             .await;
   1965         let changed_kind = fixture
   1966             .attempt(
   1967                 DurableOperationKind::Repair,
   1968                 fixture.expected_revision,
   1969                 fixture.canonical_secret(),
   1970             )
   1971             .await
   1972             .expect_err("original kind required");
   1973         let changed_revision = fixture
   1974             .attempt(
   1975                 DurableOperationKind::Import,
   1976                 fixture.expected_revision + 1,
   1977                 fixture.canonical_secret(),
   1978             )
   1979             .await
   1980             .expect_err("original revision required");
   1981         let changed_secret = fixture
   1982             .attempt(
   1983                 DurableOperationKind::Import,
   1984                 fixture.expected_revision,
   1985                 SecretKeyInput::parse(
   1986                     "0000000000000000000000000000000000000000000000000000000000000001".to_owned(),
   1987                 )
   1988                 .expect("different secret"),
   1989             )
   1990             .await
   1991             .expect_err("full secret required");
   1992         let after_operation = fixture.operations.operation().clone();
   1993         let after_state = fixture.core.snapshot();
   1994         let after_identities = fixture
   1995             .identities
   1996             .list_identities()
   1997             .await
   1998             .expect("identities");
   1999         let after_selection = fixture
   2000             .identities
   2001             .load_selected_identity()
   2002             .await
   2003             .expect("selection");
   2004         let retained = fixture
   2005             .secrets
   2006             .load(fixture.original.public_key())
   2007             .await
   2008             .expect("credential");
   2009         let mutations = fixture
   2010             .secrets
   2011             .calls()
   2012             .into_iter()
   2013             .filter(|call| {
   2014                 matches!(
   2015                     call.operation(),
   2016                     SecretStoreOperation::Put | SecretStoreOperation::Delete,
   2017                 )
   2018             })
   2019             .collect::<Vec<_>>();
   2020         assert_eq!(exact.expect("exact completed admission"), fixture.original);
   2021         assert_ne!(fixture.candidate, fixture.original);
   2022         assert_eq!(changed_kind.code(), SafeErrorCode::InvalidApplicationState);
   2023         assert_eq!(
   2024             changed_revision.code(),
   2025             SafeErrorCode::InvalidApplicationState
   2026         );
   2027         assert_eq!(
   2028             changed_secret.code(),
   2029             SafeErrorCode::InvalidApplicationState
   2030         );
   2031         assert_eq!(before_operation, after_operation);
   2032         assert_eq!(before_state, after_state);
   2033         assert_eq!(before_identities, after_identities);
   2034         assert_eq!(before_selection, after_selection);
   2035         assert_eq!(mutations.len(), 1);
   2036         assert_eq!(mutations[0].operation(), SecretStoreOperation::Put);
   2037         assert!(retained.with_exposed_secret(|value| {
   2038             fixture
   2039                 .canonical_secret()
   2040                 .with_exposed_secret(|expected| value == expected)
   2041         }));
   2042         assert!(
   2043             !format!("{changed_kind:?} {changed_revision:?} {changed_secret:?}")
   2044                 .contains(ADMISSION_SECRET)
   2045         );
   2046     }
   2047 
   2048     #[tokio::test]
   2049     async fn completed_admission_race_rejects_missing_and_rebound_custody_without_mutation() {
   2050         for rebound in [false, true] {
   2051             let fixture = CompletedAdmissionFixture::new().await;
   2052             fixture
   2053                 .secrets
   2054                 .delete(&fixture.request, fixture.original.public_key())
   2055                 .await
   2056                 .expect("remove custody");
   2057             let another_request = DurableRequestId::new_v7();
   2058             if rebound {
   2059                 fixture
   2060                     .secrets
   2061                     .put(
   2062                         &another_request,
   2063                         fixture.original.public_key(),
   2064                         fixture.canonical_secret(),
   2065                     )
   2066                     .await
   2067                     .expect("replacement custody");
   2068             }
   2069             let before_operation = fixture.operations.operation().clone();
   2070             let before_state = fixture.core.snapshot();
   2071             let before_identities = fixture
   2072                 .identities
   2073                 .list_identities()
   2074                 .await
   2075                 .expect("identities");
   2076             let before_selection = fixture
   2077                 .identities
   2078                 .load_selected_identity()
   2079                 .await
   2080                 .expect("selection");
   2081             let before_mutations = fixture
   2082                 .secrets
   2083                 .calls()
   2084                 .into_iter()
   2085                 .filter(|call| {
   2086                     matches!(
   2087                         call.operation(),
   2088                         SecretStoreOperation::Put | SecretStoreOperation::Delete,
   2089                     )
   2090                 })
   2091                 .collect::<Vec<_>>();
   2092             let error = fixture
   2093                 .attempt(
   2094                     DurableOperationKind::Import,
   2095                     fixture.expected_revision,
   2096                     fixture.canonical_secret(),
   2097                 )
   2098                 .await
   2099                 .expect_err("unbound custody must fail");
   2100             let after_operation = fixture.operations.operation().clone();
   2101             let after_state = fixture.core.snapshot();
   2102             let after_identities = fixture
   2103                 .identities
   2104                 .list_identities()
   2105                 .await
   2106                 .expect("identities");
   2107             let after_selection = fixture
   2108                 .identities
   2109                 .load_selected_identity()
   2110                 .await
   2111                 .expect("selection");
   2112             let after_mutations = fixture
   2113                 .secrets
   2114                 .calls()
   2115                 .into_iter()
   2116                 .filter(|call| {
   2117                     matches!(
   2118                         call.operation(),
   2119                         SecretStoreOperation::Put | SecretStoreOperation::Delete,
   2120                     )
   2121                 })
   2122                 .collect::<Vec<_>>();
   2123             let present = fixture
   2124                 .secrets
   2125                 .contains(fixture.original.public_key())
   2126                 .await
   2127                 .expect("availability");
   2128             let replacement_binding = if rebound {
   2129                 fixture
   2130                     .secrets
   2131                     .verify(
   2132                         &another_request,
   2133                         fixture.original.public_key(),
   2134                         fixture.canonical_secret(),
   2135                     )
   2136                     .await
   2137             } else {
   2138                 Ok(())
   2139             };
   2140             assert_eq!(
   2141                 error.code(),
   2142                 if rebound {
   2143                     SafeErrorCode::InvalidApplicationState
   2144                 } else {
   2145                     SafeErrorCode::CredentialMissing
   2146                 }
   2147             );
   2148             assert_eq!(before_operation, after_operation);
   2149             assert_eq!(before_state, after_state);
   2150             assert_eq!(before_identities, after_identities);
   2151             assert_eq!(before_selection, after_selection);
   2152             assert_eq!(before_mutations, after_mutations);
   2153             assert_eq!(present, rebound);
   2154             assert!(replacement_binding.is_ok());
   2155             assert!(!format!("{error:?}").contains(ADMISSION_SECRET));
   2156         }
   2157     }
   2158 
   2159     #[tokio::test]
   2160     async fn durable_import_covers_new_and_missing_credential_repair_paths() {
   2161         const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
   2162         for repair in [false, true] {
   2163             let core = AppCore::in_memory(RelayConfiguration::default());
   2164             let identities = InMemoryIdentityRepository::default();
   2165             let secrets = InMemorySecretStore::default();
   2166             let material = core
   2167                 .key_material()
   2168                 .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
   2169                 .expect("key material");
   2170             let (public_key, npub, secret) = material.into_parts();
   2171             drop(secret);
   2172             if repair {
   2173                 let identity = NostrIdentity::new(
   2174                     NostrIdentityReference::verify(public_key, npub.as_str().to_owned())
   2175                         .expect("identity"),
   2176                     LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing),
   2177                     None,
   2178                     IdentityCreatedAt::new(FixedClock.now()),
   2179                     None,
   2180                 )
   2181                 .expect("identity");
   2182                 identities
   2183                     .insert_identity(&identity)
   2184                     .await
   2185                     .expect("insert identity");
   2186                 identities
   2187                     .save_selected_identity(Some(public_key))
   2188                     .await
   2189                     .expect("selection");
   2190                 core.apply_transition(StateTransition::BootstrapRegistry {
   2191                     identities: vec![identity],
   2192                     selected: Some(public_key),
   2193                 })
   2194                 .expect("registry");
   2195             } else {
   2196                 core.bootstrap().expect("bootstrap");
   2197             }
   2198             let kind = if repair {
   2199                 DurableOperationKind::Repair
   2200             } else {
   2201                 DurableOperationKind::Import
   2202             };
   2203             let pending = durable_operation(
   2204                 kind,
   2205                 DurableOperationPhase::IntentRecorded,
   2206                 public_key,
   2207                 repair.then_some(SignerAvailability::CredentialMissing),
   2208             );
   2209             let request_id = pending.request_id().clone();
   2210             let operations = TestDurableRepository::fresh(pending);
   2211             let receipt = core
   2212                 .import_secret_key_durable(
   2213                     &request_id,
   2214                     core.snapshot().revision().value(),
   2215                     SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
   2216                     &identities,
   2217                     &identities,
   2218                     &secrets,
   2219                     &operations,
   2220                     &FixedClock,
   2221                 )
   2222                 .await
   2223                 .expect("durable import");
   2224             assert_eq!(receipt.identity().public_key(), public_key);
   2225             assert_eq!(
   2226                 operations.operation().phase(),
   2227                 DurableOperationPhase::Finalized
   2228             );
   2229         }
   2230     }
   2231 
   2232     #[tokio::test]
   2233     async fn removal_of_unselected_identity_preserves_the_current_selection() {
   2234         let core = AppCore::in_memory(RelayConfiguration::default());
   2235         let identities = InMemoryIdentityRepository::default();
   2236         let secrets = InMemorySecretStore::default();
   2237         let journal = InMemoryOperationJournal::default();
   2238         core.bootstrap().expect("bootstrap");
   2239         let first = core
   2240             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   2241             .await
   2242             .expect("first")
   2243             .identity()
   2244             .public_key();
   2245         let second = core
   2246             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   2247             .await
   2248             .expect("second")
   2249             .identity()
   2250             .public_key();
   2251         let token = core
   2252             .request_identity_removal(first, &FixedClock)
   2253             .expect("removal token");
   2254         let snapshot = core
   2255             .confirm_identity_removal(
   2256                 token,
   2257                 &identities,
   2258                 &identities,
   2259                 &secrets,
   2260                 &journal,
   2261                 &FixedClock,
   2262             )
   2263             .await
   2264             .expect("remove unselected identity");
   2265         assert_eq!(snapshot.selected_identity(), Some(second));
   2266 
   2267         let missing = crate::test_support::valid_test_public_key(99).expect("missing key");
   2268         assert!(
   2269             identities
   2270                 .insert_identity(&snapshot.identities()[0])
   2271                 .await
   2272                 .is_err()
   2273         );
   2274         assert!(
   2275             identities
   2276                 .save_selected_identity(Some(missing))
   2277                 .await
   2278                 .is_err()
   2279         );
   2280 
   2281         let third = core
   2282             .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   2283             .await
   2284             .expect("third")
   2285             .identity()
   2286             .public_key();
   2287         let token = core
   2288             .request_identity_removal(second, &FixedClock)
   2289             .expect("durable removal token");
   2290         let pending = durable_operation(
   2291             DurableOperationKind::Remove,
   2292             DurableOperationPhase::IntentRecorded,
   2293             second,
   2294             Some(SignerAvailability::Available),
   2295         );
   2296         let request_id = pending.request_id().clone();
   2297         let operations = TestDurableRepository::fresh(pending);
   2298         let snapshot = core
   2299             .confirm_identity_removal_durable(
   2300                 &request_id,
   2301                 token,
   2302                 &identities,
   2303                 &identities,
   2304                 &secrets,
   2305                 &operations,
   2306                 &FixedClock,
   2307             )
   2308             .await
   2309             .expect("durable unselected removal");
   2310         assert_eq!(snapshot.selected_identity(), Some(third));
   2311     }
   2312 
   2313     #[tokio::test]
   2314     async fn duplicate_missing_binding_with_orphan_credential_fails_closed() {
   2315         const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
   2316         let core = AppCore::in_memory(RelayConfiguration::default());
   2317         let identities = InMemoryIdentityRepository::default();
   2318         let secrets = InMemorySecretStore::default();
   2319         let journal = InMemoryOperationJournal::default();
   2320         let material = core
   2321             .key_material()
   2322             .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
   2323             .expect("key material");
   2324         let (public_key, npub, secret) = material.into_parts();
   2325         let identity = NostrIdentity::new(
   2326             NostrIdentityReference::verify(public_key, npub.as_str().to_owned()).expect("identity"),
   2327             LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing),
   2328             None,
   2329             IdentityCreatedAt::new(FixedClock.now()),
   2330             None,
   2331         )
   2332         .expect("identity");
   2333         identities
   2334             .insert_identity(&identity)
   2335             .await
   2336             .expect("insert identity");
   2337         identities
   2338             .save_selected_identity(Some(public_key))
   2339             .await
   2340             .expect("selection");
   2341         secrets
   2342             .put(&DurableRequestId::new_v7(), public_key, secret)
   2343             .await
   2344             .expect("credential");
   2345         core.apply_transition(StateTransition::BootstrapRegistry {
   2346             identities: vec![identity],
   2347             selected: Some(public_key),
   2348         })
   2349         .expect("registry");
   2350 
   2351         assert_eq!(
   2352             core.import_secret_key(
   2353                 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
   2354                 &identities,
   2355                 &identities,
   2356                 &secrets,
   2357                 &journal,
   2358                 &FixedClock,
   2359             )
   2360             .await
   2361             .expect_err("orphan credential must fail")
   2362             .code(),
   2363             SafeErrorCode::IdentityAlreadyExists
   2364         );
   2365         let pending = durable_operation(
   2366             DurableOperationKind::Repair,
   2367             DurableOperationPhase::IntentRecorded,
   2368             public_key,
   2369             Some(SignerAvailability::CredentialMissing),
   2370         );
   2371         let request_id = pending.request_id().clone();
   2372         let operations = TestDurableRepository::fresh(pending);
   2373         assert_eq!(
   2374             core.import_secret_key_durable(
   2375                 &request_id,
   2376                 core.snapshot().revision().value(),
   2377                 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
   2378                 &identities,
   2379                 &identities,
   2380                 &secrets,
   2381                 &operations,
   2382                 &FixedClock,
   2383             )
   2384             .await
   2385             .expect_err("orphan durable credential must fail")
   2386             .code(),
   2387             SafeErrorCode::IdentityAlreadyExists
   2388         );
   2389     }
   2390 
   2391     #[tokio::test]
   2392     async fn removing_an_active_identity_signs_out_for_legacy_and_durable_requests() {
   2393         for durable in [false, true] {
   2394             let core = AppCore::in_memory(RelayConfiguration::default());
   2395             let identities = InMemoryIdentityRepository::default();
   2396             let secrets = InMemorySecretStore::default();
   2397             let journal = InMemoryOperationJournal::default();
   2398             core.bootstrap().expect("bootstrap");
   2399             let public_key = core
   2400                 .import_secret_key(
   2401                     SecretKeyInput::parse(
   2402                         "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
   2403                             .to_owned(),
   2404                     )
   2405                     .expect("secret"),
   2406                     &identities,
   2407                     &identities,
   2408                     &secrets,
   2409                     &journal,
   2410                     &FixedClock,
   2411                 )
   2412                 .await
   2413                 .expect("identity")
   2414                 .identity()
   2415                 .public_key();
   2416             core.activate_identity(
   2417                 public_key,
   2418                 &identities,
   2419                 &identities,
   2420                 &EmptyProfiles,
   2421                 &secrets,
   2422                 &FixedClock,
   2423             )
   2424             .await
   2425             .expect("activate identity");
   2426             let token = core
   2427                 .request_identity_removal(public_key, &FixedClock)
   2428                 .expect("removal token");
   2429             let snapshot = if durable {
   2430                 let pending = durable_operation(
   2431                     DurableOperationKind::Remove,
   2432                     DurableOperationPhase::IntentRecorded,
   2433                     public_key,
   2434                     Some(SignerAvailability::Available),
   2435                 );
   2436                 let request_id = pending.request_id().clone();
   2437                 let operations = TestDurableRepository::fresh(pending);
   2438                 core.confirm_identity_removal_durable(
   2439                     &request_id,
   2440                     token,
   2441                     &identities,
   2442                     &identities,
   2443                     &secrets,
   2444                     &operations,
   2445                     &FixedClock,
   2446                 )
   2447                 .await
   2448                 .expect("durable removal")
   2449             } else {
   2450                 core.confirm_identity_removal(
   2451                     token,
   2452                     &identities,
   2453                     &identities,
   2454                     &secrets,
   2455                     &journal,
   2456                     &FixedClock,
   2457                 )
   2458                 .await
   2459                 .expect("removal")
   2460             };
   2461             assert_eq!(snapshot.session(), SessionState::SignedOut);
   2462         }
   2463     }
   2464 
   2465     #[tokio::test]
   2466     async fn identity_transaction_compensates_a_journal_phase_failure() {
   2467         let core = AppCore::in_memory(RelayConfiguration::default());
   2468         let identities = InMemoryIdentityRepository::default();
   2469         let secrets = InMemorySecretStore::default();
   2470         let journal = FailingUpdateJournal::default();
   2471         core.bootstrap().expect("bootstrap");
   2472 
   2473         assert_eq!(
   2474             core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock)
   2475                 .await
   2476                 .err()
   2477                 .expect("journal failure must be returned")
   2478                 .code(),
   2479             SafeErrorCode::StorageUnavailable
   2480         );
   2481         assert!(identities.list_identities().await.unwrap().is_empty());
   2482     }
   2483 }