app

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

ports.rs (28223B)


      1 use std::future::Future;
      2 use std::pin::Pin;
      3 use std::time::Instant;
      4 
      5 use harvestcircle_domain::{
      6     Kind0ProfileCandidate, NostrIdentity, Npub, Nsec, PublicKey, SafeError, SafeErrorCode,
      7     SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp,
      8 };
      9 use radroots_transport_nostr::RelayEndpoint;
     10 
     11 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     12 pub struct DurableRequestId(String);
     13 
     14 impl DurableRequestId {
     15     /// Validates a canonical caller-generated UUIDv7 idempotency key.
     16     ///
     17     /// # Errors
     18     ///
     19     /// Returns a safe validation error when the value is not canonical UUIDv7 text.
     20     pub fn parse(value: impl AsRef<str>) -> Result<Self, SafeError> {
     21         let value = value.as_ref();
     22         if value.len() != 36
     23             || !value.is_ascii()
     24             || value.bytes().enumerate().any(|(index, byte)| match index {
     25                 8 | 13 | 18 | 23 => byte != b'-',
     26                 19 => !matches!(byte, b'8' | b'9' | b'a' | b'b'),
     27                 _ => !matches!(byte, b'0'..=b'9' | b'a'..=b'f'),
     28             })
     29         {
     30             return Err(invalid_request_id());
     31         }
     32         let parsed = uuid::Uuid::parse_str(value).map_err(|_| invalid_request_id())?;
     33         if parsed.get_version_num() != 7 || parsed.hyphenated().to_string() != value {
     34             return Err(invalid_request_id());
     35         }
     36         Ok(Self(value.to_owned()))
     37     }
     38 
     39     #[must_use]
     40     pub fn new_v7() -> Self {
     41         Self(uuid::Uuid::now_v7().hyphenated().to_string())
     42     }
     43 
     44     #[must_use]
     45     pub fn as_str(&self) -> &str {
     46         &self.0
     47     }
     48 }
     49 
     50 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     51 pub enum DurableOperationKind {
     52     Create,
     53     Import,
     54     Repair,
     55     Remove,
     56 }
     57 
     58 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     59 pub enum DurableOperationPhase {
     60     IntentRecorded,
     61     CredentialWritten,
     62     MetadataCommitted,
     63     SelectionCommitted,
     64     CompensationPending,
     65     CredentialDeleted,
     66     MetadataDeleted,
     67     Finalized,
     68 }
     69 
     70 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     71 pub enum DurableTerminalOutcome {
     72     Completed,
     73     Cancelled,
     74     Failed,
     75 }
     76 
     77 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     78 pub struct OperationPriorState {
     79     selected_identity: Option<PublicKey>,
     80     binding_availability: Option<SignerAvailability>,
     81 }
     82 
     83 impl OperationPriorState {
     84     #[must_use]
     85     pub const fn new(
     86         selected_identity: Option<PublicKey>,
     87         binding_availability: Option<SignerAvailability>,
     88     ) -> Self {
     89         Self {
     90             selected_identity,
     91             binding_availability,
     92         }
     93     }
     94 
     95     #[must_use]
     96     pub const fn selected_identity(self) -> Option<PublicKey> {
     97         self.selected_identity
     98     }
     99 
    100     #[must_use]
    101     pub const fn binding_availability(self) -> Option<SignerAvailability> {
    102         self.binding_availability
    103     }
    104 }
    105 
    106 #[derive(Clone, Debug, Eq, PartialEq)]
    107 pub struct DurableOperationReceipt {
    108     request_id: DurableRequestId,
    109     identity: PublicKey,
    110     outcome: DurableTerminalOutcome,
    111     resulting_revision: Option<u64>,
    112     completed_at: UnixTimestamp,
    113 }
    114 
    115 impl DurableOperationReceipt {
    116     #[must_use]
    117     pub const fn new(
    118         request_id: DurableRequestId,
    119         identity: PublicKey,
    120         outcome: DurableTerminalOutcome,
    121         resulting_revision: Option<u64>,
    122         completed_at: UnixTimestamp,
    123     ) -> Self {
    124         Self {
    125             request_id,
    126             identity,
    127             outcome,
    128             resulting_revision,
    129             completed_at,
    130         }
    131     }
    132 
    133     #[must_use]
    134     pub const fn request_id(&self) -> &DurableRequestId {
    135         &self.request_id
    136     }
    137 
    138     #[must_use]
    139     pub const fn identity(&self) -> PublicKey {
    140         self.identity
    141     }
    142 
    143     #[must_use]
    144     pub const fn outcome(&self) -> DurableTerminalOutcome {
    145         self.outcome
    146     }
    147 
    148     #[must_use]
    149     pub const fn resulting_revision(&self) -> Option<u64> {
    150         self.resulting_revision
    151     }
    152 
    153     #[must_use]
    154     pub const fn completed_at(&self) -> UnixTimestamp {
    155         self.completed_at
    156     }
    157 }
    158 
    159 #[derive(Clone, Debug, Eq, PartialEq)]
    160 pub struct DurableIdentityOperation {
    161     request_id: DurableRequestId,
    162     kind: DurableOperationKind,
    163     identity: PublicKey,
    164     expected_revision: Option<u64>,
    165     phase: DurableOperationPhase,
    166     prior: OperationPriorState,
    167     updated_at: UnixTimestamp,
    168     diagnostic: Option<OperationDiagnostic>,
    169     terminal: Option<DurableOperationReceipt>,
    170 }
    171 
    172 impl DurableIdentityOperation {
    173     #[allow(clippy::too_many_arguments)]
    174     #[must_use]
    175     pub const fn new(
    176         request_id: DurableRequestId,
    177         kind: DurableOperationKind,
    178         identity: PublicKey,
    179         expected_revision: Option<u64>,
    180         phase: DurableOperationPhase,
    181         prior: OperationPriorState,
    182         updated_at: UnixTimestamp,
    183         diagnostic: Option<OperationDiagnostic>,
    184         terminal: Option<DurableOperationReceipt>,
    185     ) -> Self {
    186         Self {
    187             request_id,
    188             kind,
    189             identity,
    190             expected_revision,
    191             phase,
    192             prior,
    193             updated_at,
    194             diagnostic,
    195             terminal,
    196         }
    197     }
    198 
    199     #[must_use]
    200     pub const fn request_id(&self) -> &DurableRequestId {
    201         &self.request_id
    202     }
    203     #[must_use]
    204     pub const fn kind(&self) -> DurableOperationKind {
    205         self.kind
    206     }
    207     #[must_use]
    208     pub const fn identity(&self) -> PublicKey {
    209         self.identity
    210     }
    211     #[must_use]
    212     pub const fn expected_revision(&self) -> Option<u64> {
    213         self.expected_revision
    214     }
    215     #[must_use]
    216     pub const fn phase(&self) -> DurableOperationPhase {
    217         self.phase
    218     }
    219     #[must_use]
    220     pub const fn prior(&self) -> OperationPriorState {
    221         self.prior
    222     }
    223     #[must_use]
    224     pub const fn updated_at(&self) -> UnixTimestamp {
    225         self.updated_at
    226     }
    227     #[must_use]
    228     pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
    229         self.diagnostic
    230     }
    231     #[must_use]
    232     pub const fn terminal(&self) -> Option<&DurableOperationReceipt> {
    233         self.terminal.as_ref()
    234     }
    235 }
    236 
    237 #[derive(Clone, Debug, Eq, PartialEq)]
    238 pub enum DurableOperationStart {
    239     Started(DurableIdentityOperation),
    240     Existing(DurableIdentityOperation),
    241 }
    242 
    243 const fn invalid_request_id() -> SafeError {
    244     SafeError::new(
    245         SafeErrorCode::InvalidApplicationState,
    246         SafeMessage::new("The request identifier is invalid."),
    247     )
    248 }
    249 
    250 pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
    251 
    252 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    253 pub enum ProfileRefreshStatus {
    254     Success,
    255     Offline,
    256     InvalidData,
    257 }
    258 
    259 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    260 pub enum RelayFetchCompleteness {
    261     Complete,
    262     Partial,
    263 }
    264 
    265 #[derive(Clone, Debug, Eq, PartialEq)]
    266 pub struct ProfileFetchResult {
    267     candidate: Option<Kind0ProfileCandidate>,
    268     completeness: RelayFetchCompleteness,
    269 }
    270 
    271 impl ProfileFetchResult {
    272     #[must_use]
    273     pub const fn complete(candidate: Option<Kind0ProfileCandidate>) -> Self {
    274         Self {
    275             candidate,
    276             completeness: RelayFetchCompleteness::Complete,
    277         }
    278     }
    279 
    280     #[must_use]
    281     pub const fn partial(candidate: Option<Kind0ProfileCandidate>) -> Self {
    282         Self {
    283             candidate,
    284             completeness: RelayFetchCompleteness::Partial,
    285         }
    286     }
    287 
    288     #[must_use]
    289     pub fn into_parts(self) -> (Option<Kind0ProfileCandidate>, RelayFetchCompleteness) {
    290         (self.candidate, self.completeness)
    291     }
    292 }
    293 
    294 #[derive(Clone, Debug, Eq, PartialEq)]
    295 pub struct CachedProfile {
    296     candidate: Kind0ProfileCandidate,
    297     refreshed_at: UnixTimestamp,
    298     refresh_status: ProfileRefreshStatus,
    299 }
    300 
    301 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    302 pub enum IdentityPreferenceKey {
    303     NamespaceProbe,
    304 }
    305 
    306 #[cfg(test)]
    307 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    308 pub enum IdentityOperationKind {
    309     Add,
    310     Import,
    311     Remove,
    312 }
    313 
    314 #[cfg(test)]
    315 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    316 pub enum IdentityOperationPhase {
    317     IntentRecorded,
    318     CredentialWritten,
    319     MetadataCommitted,
    320     CompensationPending,
    321     CredentialDeleted,
    322     MetadataDeleted,
    323 }
    324 
    325 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    326 pub enum OperationDiagnostic {
    327     StorageUnavailable,
    328     KeyringUnavailable,
    329     CredentialMissing,
    330     CompensationFailed,
    331     Conflict,
    332     Expired,
    333 }
    334 
    335 #[cfg(test)]
    336 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
    337 pub struct OperationId(u64);
    338 
    339 #[cfg(test)]
    340 impl OperationId {
    341     #[must_use]
    342     pub const fn from_raw(value: u64) -> Self {
    343         Self(value)
    344     }
    345 
    346     #[must_use]
    347     pub const fn as_raw(self) -> u64 {
    348         self.0
    349     }
    350 }
    351 
    352 #[cfg(test)]
    353 #[derive(Clone, Debug, Eq, PartialEq)]
    354 pub struct PendingIdentityOperation {
    355     id: OperationId,
    356     kind: IdentityOperationKind,
    357     subject: PublicKey,
    358     phase: IdentityOperationPhase,
    359     updated_at: UnixTimestamp,
    360     diagnostic: Option<OperationDiagnostic>,
    361 }
    362 
    363 #[cfg(test)]
    364 impl PendingIdentityOperation {
    365     #[must_use]
    366     pub const fn new(
    367         id: OperationId,
    368         kind: IdentityOperationKind,
    369         subject: PublicKey,
    370         phase: IdentityOperationPhase,
    371         updated_at: UnixTimestamp,
    372         diagnostic: Option<OperationDiagnostic>,
    373     ) -> Self {
    374         Self {
    375             id,
    376             kind,
    377             subject,
    378             phase,
    379             updated_at,
    380             diagnostic,
    381         }
    382     }
    383 
    384     #[must_use]
    385     pub const fn id(&self) -> OperationId {
    386         self.id
    387     }
    388     #[must_use]
    389     pub const fn kind(&self) -> IdentityOperationKind {
    390         self.kind
    391     }
    392     #[must_use]
    393     pub const fn subject(&self) -> PublicKey {
    394         self.subject
    395     }
    396     #[must_use]
    397     pub const fn phase(&self) -> IdentityOperationPhase {
    398         self.phase
    399     }
    400     #[must_use]
    401     pub const fn updated_at(&self) -> UnixTimestamp {
    402         self.updated_at
    403     }
    404     #[must_use]
    405     pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
    406         self.diagnostic
    407     }
    408 }
    409 
    410 impl CachedProfile {
    411     #[must_use]
    412     pub const fn new(
    413         candidate: Kind0ProfileCandidate,
    414         refreshed_at: UnixTimestamp,
    415         refresh_status: ProfileRefreshStatus,
    416     ) -> Self {
    417         Self {
    418             candidate,
    419             refreshed_at,
    420             refresh_status,
    421         }
    422     }
    423 
    424     #[must_use]
    425     pub const fn candidate(&self) -> &Kind0ProfileCandidate {
    426         &self.candidate
    427     }
    428 
    429     #[must_use]
    430     pub const fn refreshed_at(&self) -> UnixTimestamp {
    431         self.refreshed_at
    432     }
    433 
    434     #[must_use]
    435     pub const fn refresh_status(&self) -> ProfileRefreshStatus {
    436         self.refresh_status
    437     }
    438 }
    439 
    440 pub trait IdentityRepository: Send + Sync {
    441     /// Lists saved public identity records in deterministic order.
    442     ///
    443     /// # Errors
    444     ///
    445     /// Returns a safe storage error when records cannot be read.
    446     fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>>;
    447     /// Finds one saved public identity record.
    448     ///
    449     /// # Errors
    450     ///
    451     /// Returns a safe storage error when the lookup cannot complete.
    452     fn find_identity(
    453         &self,
    454         public_key: PublicKey,
    455     ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>>;
    456     /// Inserts one public identity record.
    457     ///
    458     /// # Errors
    459     ///
    460     /// Returns a safe storage error when the durable write fails.
    461     fn insert_identity<'a>(
    462         &'a self,
    463         identity: &'a NostrIdentity,
    464     ) -> BoxFuture<'a, Result<(), SafeError>>;
    465     /// Updates one existing public identity record.
    466     ///
    467     /// # Errors
    468     ///
    469     /// Returns a safe storage or identity-not-found error when the durable
    470     /// update cannot complete.
    471     fn update_identity<'a>(
    472         &'a self,
    473         identity: &'a NostrIdentity,
    474     ) -> BoxFuture<'a, Result<(), SafeError>>;
    475     /// Removes one public identity record.
    476     ///
    477     /// # Errors
    478     ///
    479     /// Returns a safe storage error when the durable delete fails.
    480     fn remove_identity(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>;
    481 }
    482 
    483 pub trait ProfileRepository: Send + Sync {
    484     /// Loads cached public profile metadata.
    485     ///
    486     /// # Errors
    487     ///
    488     /// Returns a safe storage error when the cache cannot be read.
    489     fn load_profile(
    490         &self,
    491         public_key: PublicKey,
    492     ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>>;
    493     /// Saves a verified kind-0 profile candidate.
    494     ///
    495     /// # Errors
    496     ///
    497     /// Returns a safe storage error when the cache cannot be committed.
    498     fn save_profile<'a>(
    499         &'a self,
    500         profile: &'a CachedProfile,
    501     ) -> BoxFuture<'a, Result<(), SafeError>>;
    502     /// Records the result of a profile refresh without replacing cached metadata.
    503     ///
    504     /// # Errors
    505     ///
    506     /// Returns a safe storage error when the cache cannot be committed.
    507     fn record_refresh_status<'a>(
    508         &'a self,
    509         public_key: PublicKey,
    510         refreshed_at: UnixTimestamp,
    511         status: ProfileRefreshStatus,
    512     ) -> BoxFuture<'a, Result<(), SafeError>>;
    513     /// Removes cached profile metadata for an identity.
    514     ///
    515     /// # Errors
    516     ///
    517     /// Returns a safe storage error when the cache cannot be deleted.
    518     fn remove_profile(&self, public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>;
    519 }
    520 
    521 pub trait IdentityNamespaceRepository: Send + Sync {
    522     /// Reads one internal non-secret identity-scoped value.
    523     ///
    524     /// # Errors
    525     ///
    526     /// Returns a safe storage error when the value cannot be read.
    527     fn get_value<'a>(
    528         &'a self,
    529         owner: PublicKey,
    530         key: IdentityPreferenceKey,
    531     ) -> BoxFuture<'a, Result<Option<String>, SafeError>>;
    532     /// Writes one internal non-secret identity-scoped value.
    533     ///
    534     /// # Errors
    535     ///
    536     /// Returns a safe storage error when the value cannot be committed.
    537     fn set_value<'a>(
    538         &'a self,
    539         owner: PublicKey,
    540         key: IdentityPreferenceKey,
    541         value: &'a str,
    542     ) -> BoxFuture<'a, Result<(), SafeError>>;
    543     /// Removes all internal values owned by an identity.
    544     ///
    545     /// # Errors
    546     ///
    547     /// Returns a safe storage error when cleanup cannot be committed.
    548     fn clear_owner(&self, owner: PublicKey) -> BoxFuture<'_, Result<(), SafeError>>;
    549 }
    550 
    551 pub trait AppStateRepository: Send + Sync {
    552     /// Loads the persisted selected identity.
    553     ///
    554     /// # Errors
    555     ///
    556     /// Returns a safe storage error when application state cannot be read.
    557     fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>>;
    558     /// Persists the selected identity or the empty selection.
    559     ///
    560     /// # Errors
    561     ///
    562     /// Returns a safe storage error when application state cannot be committed.
    563     fn save_selected_identity(
    564         &self,
    565         public_key: Option<PublicKey>,
    566     ) -> BoxFuture<'_, Result<(), SafeError>>;
    567 }
    568 
    569 #[cfg(test)]
    570 pub trait OperationJournal: Send + Sync {
    571     /// Records one cross-resource identity operation intent.
    572     ///
    573     /// # Errors
    574     ///
    575     /// Returns a safe storage error when the entry cannot be committed.
    576     fn begin_operation<'a>(
    577         &'a self,
    578         kind: IdentityOperationKind,
    579         subject: PublicKey,
    580         updated_at: UnixTimestamp,
    581     ) -> BoxFuture<'a, Result<OperationId, SafeError>>;
    582     /// Advances an operation to a durable recovery phase.
    583     ///
    584     /// # Errors
    585     ///
    586     /// Returns a safe storage error when the entry cannot be updated.
    587     fn update_operation<'a>(
    588         &'a self,
    589         id: OperationId,
    590         phase: IdentityOperationPhase,
    591         updated_at: UnixTimestamp,
    592         diagnostic: Option<OperationDiagnostic>,
    593     ) -> BoxFuture<'a, Result<(), SafeError>>;
    594     /// Loads all unfinished operations in deterministic order.
    595     ///
    596     /// # Errors
    597     ///
    598     /// Returns a safe storage error when entries cannot be read.
    599     fn list_pending_operations(
    600         &self,
    601     ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, SafeError>>;
    602     /// Deletes one fully reconciled operation entry.
    603     ///
    604     /// # Errors
    605     ///
    606     /// Returns a safe storage error when finalization cannot be committed.
    607     fn finalize_operation(&self, id: OperationId) -> BoxFuture<'_, Result<(), SafeError>>;
    608 }
    609 
    610 pub trait DurableOperationRepository: Send + Sync {
    611     /// Records one idempotent durable operation or returns the existing matching request.
    612     ///
    613     /// # Errors
    614     ///
    615     /// Returns a safe conflict or storage error when the request cannot be recorded.
    616     #[allow(clippy::too_many_arguments)]
    617     fn begin_durable_operation<'a>(
    618         &'a self,
    619         request_id: &'a DurableRequestId,
    620         kind: DurableOperationKind,
    621         identity: PublicKey,
    622         expected_revision: Option<u64>,
    623         prior: OperationPriorState,
    624         updated_at: UnixTimestamp,
    625     ) -> BoxFuture<'a, Result<DurableOperationStart, SafeError>>;
    626     /// Loads one durable operation by its idempotency key.
    627     ///
    628     /// # Errors
    629     ///
    630     /// Returns a safe storage error when the lookup cannot complete.
    631     fn load_durable_operation<'a>(
    632         &'a self,
    633         request_id: &'a DurableRequestId,
    634     ) -> BoxFuture<'a, Result<Option<DurableIdentityOperation>, SafeError>>;
    635     /// Advances one operation only from the caller's expected phase.
    636     ///
    637     /// # Errors
    638     ///
    639     /// Returns a safe conflict or storage error when the transition cannot commit.
    640     fn advance_durable_operation<'a>(
    641         &'a self,
    642         request_id: &'a DurableRequestId,
    643         expected_phase: DurableOperationPhase,
    644         next_phase: DurableOperationPhase,
    645         updated_at: UnixTimestamp,
    646         diagnostic: Option<OperationDiagnostic>,
    647     ) -> BoxFuture<'a, Result<DurableIdentityOperation, SafeError>>;
    648     /// Finalizes one operation and durably retains its recoverable receipt.
    649     ///
    650     /// # Errors
    651     ///
    652     /// Returns a safe conflict or storage error when finalization cannot commit.
    653     fn finalize_durable_operation<'a>(
    654         &'a self,
    655         request_id: &'a DurableRequestId,
    656         expected_phase: DurableOperationPhase,
    657         outcome: DurableTerminalOutcome,
    658         resulting_revision: Option<u64>,
    659         updated_at: UnixTimestamp,
    660     ) -> BoxFuture<'a, Result<DurableOperationReceipt, SafeError>>;
    661     /// Lists unfinished operations in deterministic request order.
    662     ///
    663     /// # Errors
    664     ///
    665     /// Returns a safe storage error when operations cannot be read.
    666     fn list_unfinished_durable_operations(
    667         &self,
    668     ) -> BoxFuture<'_, Result<Vec<DurableIdentityOperation>, SafeError>>;
    669 }
    670 
    671 pub trait NostrClient: Send + Sync {
    672     fn fetch_profile<'a>(
    673         &'a self,
    674         public_key: PublicKey,
    675         relays: &'a [RelayEndpoint],
    676         deadline: Instant,
    677     ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>>;
    678 }
    679 
    680 pub struct GeneratedKeyMaterial {
    681     public_key: PublicKey,
    682     npub: Npub,
    683     secret: SecretKeyInput,
    684     nsec: Nsec,
    685 }
    686 
    687 impl GeneratedKeyMaterial {
    688     #[must_use]
    689     pub const fn new(
    690         public_key: PublicKey,
    691         npub: Npub,
    692         secret: SecretKeyInput,
    693         nsec: Nsec,
    694     ) -> Self {
    695         Self {
    696             public_key,
    697             npub,
    698             secret,
    699             nsec,
    700         }
    701     }
    702 
    703     #[must_use]
    704     pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) {
    705         (self.public_key, self.npub, self.secret, self.nsec)
    706     }
    707 }
    708 
    709 pub struct ImportedKeyMaterial {
    710     public_key: PublicKey,
    711     npub: Npub,
    712     secret: SecretKeyInput,
    713 }
    714 
    715 impl ImportedKeyMaterial {
    716     #[must_use]
    717     pub const fn new(public_key: PublicKey, npub: Npub, secret: SecretKeyInput) -> Self {
    718         Self {
    719             public_key,
    720             npub,
    721             secret,
    722         }
    723     }
    724 
    725     #[must_use]
    726     pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) {
    727         (self.public_key, self.npub, self.secret)
    728     }
    729 }
    730 
    731 pub trait KeyMaterialProvider: Send + Sync {
    732     /// Generates one keypair from host-provided cryptographic entropy.
    733     ///
    734     /// # Errors
    735     ///
    736     /// Returns a redacted key or entropy error.
    737     fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError>;
    738 
    739     /// Canonicalizes imported secret material and derives its public identity.
    740     ///
    741     /// # Errors
    742     ///
    743     /// Returns a redacted validation error.
    744     fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError>;
    745 }
    746 
    747 pub trait Clock: Send + Sync {
    748     fn now(&self) -> UnixTimestamp;
    749 }
    750 
    751 #[cfg(test)]
    752 mod tests {
    753     use std::time::Instant;
    754 
    755     use std::sync::Mutex;
    756 
    757     use harvestcircle_domain::{NostrIdentity, PublicKey, SafeError, UnixTimestamp};
    758     use radroots_transport_nostr::RelayEndpoint;
    759 
    760     use super::{
    761         AppStateRepository, BoxFuture, CachedProfile, Clock, DurableOperationReceipt,
    762         DurableRequestId, DurableTerminalOutcome, IdentityNamespaceRepository,
    763         IdentityOperationKind, IdentityOperationPhase, IdentityPreferenceKey, IdentityRepository,
    764         NostrClient, OperationDiagnostic, OperationId, OperationJournal, PendingIdentityOperation,
    765         ProfileFetchResult, ProfileRefreshStatus, ProfileRepository,
    766     };
    767 
    768     #[tokio::test]
    769     async fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() {
    770         let request =
    771             DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000031").expect("request id");
    772         let receipt = DurableOperationReceipt::new(
    773             request.clone(),
    774             PublicKey::from_bytes([7; 32]).expect("valid public key"),
    775             DurableTerminalOutcome::Completed,
    776             Some(42),
    777             UnixTimestamp::from_seconds(7).expect("time"),
    778         );
    779         assert_eq!(receipt.request_id(), &request);
    780         assert_eq!(receipt.resulting_revision(), Some(42));
    781         assert_eq!(receipt.completed_at().as_seconds(), 7);
    782         for invalid in [
    783             "",
    784             "contains space",
    785             "01890f3e-7b1c-4000-8000-000000000031",
    786             "01890F3E-7B1C-7000-8000-000000000031",
    787             "01890f3e-7b1c-6000-8000-000000000031",
    788             "01890f3e-7b1c-7000-7000-000000000031",
    789         ] {
    790             assert!(DurableRequestId::parse(invalid).is_err());
    791         }
    792         let oversized = "0".repeat(1_000_000);
    793         assert!(DurableRequestId::parse(&oversized).is_err());
    794     }
    795 
    796     #[tokio::test]
    797     async fn durable_request_id_source_is_stateless_canonical_and_unique() {
    798         let first = DurableRequestId::new_v7();
    799         let second = DurableRequestId::new_v7();
    800 
    801         assert_eq!(first.as_str().len(), 36);
    802         assert_eq!(first.as_str().as_bytes()[14], b'7');
    803         assert!(matches!(
    804             first.as_str().as_bytes()[19],
    805             b'8' | b'9' | b'a' | b'b'
    806         ));
    807         assert_eq!(
    808             DurableRequestId::parse(first.as_str()).expect("generated UUIDv7"),
    809             first
    810         );
    811         assert_ne!(first, second);
    812     }
    813 
    814     #[derive(Default)]
    815     struct FakePorts {
    816         selected: Mutex<Option<PublicKey>>,
    817     }
    818 
    819     impl IdentityRepository for FakePorts {
    820         fn list_identities(&self) -> BoxFuture<'_, Result<Vec<NostrIdentity>, SafeError>> {
    821             Box::pin(async { Ok(Vec::new()) })
    822         }
    823 
    824         fn find_identity(
    825             &self,
    826             _public_key: PublicKey,
    827         ) -> BoxFuture<'_, Result<Option<NostrIdentity>, SafeError>> {
    828             Box::pin(async { Ok(None) })
    829         }
    830 
    831         fn insert_identity<'a>(
    832             &'a self,
    833             _identity: &'a NostrIdentity,
    834         ) -> BoxFuture<'a, Result<(), SafeError>> {
    835             Box::pin(async { Ok(()) })
    836         }
    837 
    838         fn update_identity<'a>(
    839             &'a self,
    840             _identity: &'a NostrIdentity,
    841         ) -> BoxFuture<'a, Result<(), SafeError>> {
    842             Box::pin(async { Ok(()) })
    843         }
    844 
    845         fn remove_identity(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
    846             Box::pin(async { Ok(()) })
    847         }
    848     }
    849 
    850     impl ProfileRepository for FakePorts {
    851         fn load_profile(
    852             &self,
    853             _public_key: PublicKey,
    854         ) -> BoxFuture<'_, Result<Option<CachedProfile>, SafeError>> {
    855             Box::pin(async { Ok(None) })
    856         }
    857 
    858         fn save_profile<'a>(
    859             &'a self,
    860             _profile: &'a CachedProfile,
    861         ) -> BoxFuture<'a, Result<(), SafeError>> {
    862             Box::pin(async { Ok(()) })
    863         }
    864 
    865         fn record_refresh_status<'a>(
    866             &'a self,
    867             _public_key: PublicKey,
    868             _refreshed_at: UnixTimestamp,
    869             _status: ProfileRefreshStatus,
    870         ) -> BoxFuture<'a, Result<(), SafeError>> {
    871             Box::pin(async { Ok(()) })
    872         }
    873 
    874         fn remove_profile(&self, _public_key: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
    875             Box::pin(async { Ok(()) })
    876         }
    877     }
    878 
    879     impl IdentityNamespaceRepository for FakePorts {
    880         fn get_value<'a>(
    881             &'a self,
    882             _owner: PublicKey,
    883             _key: IdentityPreferenceKey,
    884         ) -> BoxFuture<'a, Result<Option<String>, SafeError>> {
    885             Box::pin(async { Ok(None) })
    886         }
    887 
    888         fn set_value<'a>(
    889             &'a self,
    890             _owner: PublicKey,
    891             _key: IdentityPreferenceKey,
    892             _value: &'a str,
    893         ) -> BoxFuture<'a, Result<(), SafeError>> {
    894             Box::pin(async { Ok(()) })
    895         }
    896 
    897         fn clear_owner(&self, _owner: PublicKey) -> BoxFuture<'_, Result<(), SafeError>> {
    898             Box::pin(async { Ok(()) })
    899         }
    900     }
    901 
    902     impl AppStateRepository for FakePorts {
    903         fn load_selected_identity(&self) -> BoxFuture<'_, Result<Option<PublicKey>, SafeError>> {
    904             Box::pin(async move { Ok(*self.selected.lock().expect("selected lock")) })
    905         }
    906 
    907         fn save_selected_identity(
    908             &self,
    909             public_key: Option<PublicKey>,
    910         ) -> BoxFuture<'_, Result<(), SafeError>> {
    911             Box::pin(async move {
    912                 *self.selected.lock().expect("selected lock") = public_key;
    913                 Ok(())
    914             })
    915         }
    916     }
    917 
    918     impl OperationJournal for FakePorts {
    919         fn begin_operation<'a>(
    920             &'a self,
    921             _kind: IdentityOperationKind,
    922             _subject: PublicKey,
    923             _updated_at: UnixTimestamp,
    924         ) -> BoxFuture<'a, Result<OperationId, SafeError>> {
    925             Box::pin(async { Ok(OperationId::from_raw(1)) })
    926         }
    927 
    928         fn update_operation<'a>(
    929             &'a self,
    930             _id: OperationId,
    931             _phase: IdentityOperationPhase,
    932             _updated_at: UnixTimestamp,
    933             _diagnostic: Option<OperationDiagnostic>,
    934         ) -> BoxFuture<'a, Result<(), SafeError>> {
    935             Box::pin(async { Ok(()) })
    936         }
    937 
    938         fn list_pending_operations(
    939             &self,
    940         ) -> BoxFuture<'_, Result<Vec<PendingIdentityOperation>, SafeError>> {
    941             Box::pin(async { Ok(Vec::new()) })
    942         }
    943 
    944         fn finalize_operation(&self, _id: OperationId) -> BoxFuture<'_, Result<(), SafeError>> {
    945             Box::pin(async { Ok(()) })
    946         }
    947     }
    948 
    949     impl NostrClient for FakePorts {
    950         fn fetch_profile<'a>(
    951             &'a self,
    952             _public_key: PublicKey,
    953             _relays: &'a [RelayEndpoint],
    954             _deadline: Instant,
    955         ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
    956             Box::pin(async { Ok(ProfileFetchResult::complete(None)) })
    957         }
    958     }
    959 
    960     impl Clock for FakePorts {
    961         fn now(&self) -> UnixTimestamp {
    962             UnixTimestamp::from_seconds(1).expect("valid fake time")
    963         }
    964     }
    965 
    966     fn assert_send_sync<T: Send + Sync>() {}
    967 
    968     #[tokio::test]
    969     async fn ports_accept_send_sync_test_fakes() {
    970         assert_send_sync::<FakePorts>();
    971 
    972         let ports = FakePorts::default();
    973         ports
    974             .save_selected_identity(Some(
    975                 PublicKey::from_bytes([7_u8; 32]).expect("valid public key"),
    976             ))
    977             .await
    978             .expect("save selection");
    979         assert_eq!(
    980             ports
    981                 .load_selected_identity()
    982                 .await
    983                 .expect("load selection"),
    984             Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key"))
    985         );
    986         assert_eq!(ports.now().as_seconds(), 1);
    987     }
    988 }