app

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

commands.rs (63758B)


      1 use std::collections::BTreeMap;
      2 use std::fmt::{self, Display, Formatter};
      3 use std::num::NonZeroUsize;
      4 use std::path::{Path, PathBuf};
      5 use std::sync::atomic::{AtomicU8, Ordering};
      6 use std::sync::{Arc, Mutex};
      7 use std::time::{Duration, SystemTime, UNIX_EPOCH};
      8 
      9 use directories::BaseDirs;
     10 use harvestcircle_application::{
     11     Clock, DurableRequestId, GeneratedKeyRecoveryHandle, MAX_CONFIGURED_RELAYS, RelayConfiguration,
     12     RelayEndpointInput, RelayUrlPolicy, RemovalConfirmationToken, SecretStore,
     13     relay_configuration_from_endpoints,
     14 };
     15 use harvestcircle_domain::{
     16     PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
     17 };
     18 use harvestcircle_nostr::SdkNostrClient;
     19 use harvestcircle_runtime::{
     20     RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
     21 };
     22 use harvestcircle_storage::OsKeyringSecretStore;
     23 use radroots_runtime_paths::{
     24     InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
     25     RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
     26 };
     27 use radroots_service_sqlite::MigrationBuildIdentity;
     28 
     29 use crate::{
     30     AppSnapshotDto, IdentityDto, RelayDestinationDto, RelayEndpointDto, WireErrorCategory,
     31     WireErrorCode, WireRecoveryAction,
     32     contract::{
     33         BUILD_JAVA_TOOLCHAIN, BUILD_KOTLIN_TOOLCHAIN, BUILD_PROVENANCE_DIGEST,
     34         BUILD_RADROOTS_REVISION, BUILD_RUST_TOOLCHAIN, BUILD_SOURCE_COMMIT,
     35         BUILD_SOURCE_DATE_EPOCH, BUILD_SOURCE_DIRTY, DISTRIBUTION_PACKAGE_VERSION,
     36         FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR,
     37         MINIMUM_SCHEMA_VERSION, PRODUCT_COORDINATE_DIGEST, PRODUCT_VERSION,
     38         SNAPSHOT_SCHEMA_VERSION, SOURCE_FOUNDATION_BASELINE, SOURCE_PROVENANCE_DIGEST,
     39     },
     40     dto::error_policy,
     41     host_runtime::HostRuntime,
     42     keyring_worker::BoundedKeyringWorker,
     43 };
     44 
     45 pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64;
     46 const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000;
     47 
     48 #[derive(Clone, Eq, PartialEq)]
     49 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
     50 pub struct RequestContextDto {
     51     pub request_id: String,
     52     pub expected_revision: u64,
     53     pub deadline_millis: u64,
     54 }
     55 
     56 impl fmt::Debug for RequestContextDto {
     57     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
     58         formatter
     59             .debug_struct("RequestContextDto")
     60             .field("request_id", &"<redacted>")
     61             .field("expected_revision", &self.expected_revision)
     62             .field("deadline_millis", &self.deadline_millis)
     63             .finish()
     64     }
     65 }
     66 
     67 #[derive(Clone, Eq, PartialEq)]
     68 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
     69 pub struct RelayBootstrapInputDto {
     70     pub endpoints: Vec<RelayEndpointDto>,
     71 }
     72 
     73 impl fmt::Debug for RelayBootstrapInputDto {
     74     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
     75         formatter
     76             .debug_struct("RelayBootstrapInputDto")
     77             .field("endpoint_count", &self.endpoints.len())
     78             .finish()
     79     }
     80 }
     81 
     82 #[derive(Clone, Eq, PartialEq)]
     83 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
     84 pub struct RuntimeOpenInputDto {
     85     pub development_mode: bool,
     86     pub explicit_data_directory: Option<String>,
     87     pub relay_input: RelayBootstrapInputDto,
     88 }
     89 
     90 impl fmt::Debug for RuntimeOpenInputDto {
     91     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
     92         formatter
     93             .debug_struct("RuntimeOpenInputDto")
     94             .field("development_mode", &self.development_mode)
     95             .field(
     96                 "explicit_data_directory",
     97                 &self.explicit_data_directory.as_ref().map(|_| "<redacted>"),
     98             )
     99             .field("relay_endpoint_count", &self.relay_input.endpoints.len())
    100             .finish()
    101     }
    102 }
    103 
    104 #[derive(Clone, Eq, PartialEq)]
    105 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
    106 pub struct IdentityCommandReceiptDto {
    107     pub request_id: String,
    108     pub committed_revision: u64,
    109     pub snapshot: AppSnapshotDto,
    110 }
    111 
    112 impl fmt::Debug for IdentityCommandReceiptDto {
    113     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    114         formatter
    115             .debug_struct("IdentityCommandReceiptDto")
    116             .field("request_id", &"<redacted>")
    117             .field("committed_revision", &self.committed_revision)
    118             .field("snapshot", &self.snapshot)
    119             .finish()
    120     }
    121 }
    122 
    123 #[derive(Clone, Debug, Eq, PartialEq)]
    124 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
    125 pub struct CompatibilityDescriptor {
    126     pub contract_id: String,
    127     pub product_version: String,
    128     pub cargo_package_version: String,
    129     pub distribution_package_version: String,
    130     pub contract_major: u16,
    131     pub contract_minor: u16,
    132     pub contract_hash: String,
    133     pub product_coordinate_digest: String,
    134     pub snapshot_schema_version: u32,
    135     pub minimum_schema_version: u32,
    136     pub current_schema_version: u32,
    137     pub source_provenance_digest: String,
    138     pub source_foundation_baseline: String,
    139 }
    140 
    141 #[derive(Clone, Debug, Eq, PartialEq)]
    142 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
    143 pub struct CompatibilityExpectation {
    144     pub contract_id: String,
    145     pub contract_major: u16,
    146     pub minimum_contract_minor: u16,
    147     pub contract_hash: String,
    148     pub product_coordinate_digest: String,
    149     pub snapshot_schema_version: u32,
    150     pub minimum_schema_version: u32,
    151     pub maximum_schema_version: u32,
    152 }
    153 
    154 #[derive(Clone, Debug, Eq, PartialEq)]
    155 #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
    156 pub struct BuildInfoDto {
    157     pub source_commit: String,
    158     pub source_dirty: String,
    159     pub radroots_revision: String,
    160     pub rust_toolchain: String,
    161     pub java_toolchain: String,
    162     pub kotlin_toolchain: String,
    163     pub provenance_digest: String,
    164     pub source_date_epoch: u64,
    165     pub ffi_contract_id: String,
    166     pub ffi_contract_hash: String,
    167     pub snapshot_schema_version: u32,
    168     pub minimum_storage_schema_version: u32,
    169     pub current_storage_schema_version: u32,
    170 }
    171 
    172 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    173 #[must_use]
    174 pub fn build_info() -> BuildInfoDto {
    175     BuildInfoDto {
    176         source_commit: BUILD_SOURCE_COMMIT.to_owned(),
    177         source_dirty: BUILD_SOURCE_DIRTY.to_owned(),
    178         radroots_revision: BUILD_RADROOTS_REVISION.to_owned(),
    179         rust_toolchain: BUILD_RUST_TOOLCHAIN.to_owned(),
    180         java_toolchain: BUILD_JAVA_TOOLCHAIN.to_owned(),
    181         kotlin_toolchain: BUILD_KOTLIN_TOOLCHAIN.to_owned(),
    182         provenance_digest: BUILD_PROVENANCE_DIGEST.to_owned(),
    183         source_date_epoch: BUILD_SOURCE_DATE_EPOCH
    184             .parse()
    185             .expect("validated build epoch"),
    186         ffi_contract_id: FFI_CONTRACT_ID.to_owned(),
    187         ffi_contract_hash: FFI_CONTRACT_HASH.to_owned(),
    188         snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
    189         minimum_storage_schema_version: MINIMUM_SCHEMA_VERSION,
    190         current_storage_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION,
    191     }
    192 }
    193 
    194 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    195 pub fn compatibility_descriptor() -> CompatibilityDescriptor {
    196     CompatibilityDescriptor {
    197         contract_id: FFI_CONTRACT_ID.to_owned(),
    198         product_version: PRODUCT_VERSION.to_owned(),
    199         cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(),
    200         distribution_package_version: DISTRIBUTION_PACKAGE_VERSION.to_owned(),
    201         contract_major: FFI_CONTRACT_MAJOR,
    202         contract_minor: FFI_CONTRACT_MINOR,
    203         contract_hash: FFI_CONTRACT_HASH.to_owned(),
    204         product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(),
    205         snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
    206         minimum_schema_version: MINIMUM_SCHEMA_VERSION,
    207         current_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION,
    208         source_provenance_digest: SOURCE_PROVENANCE_DIGEST.to_owned(),
    209         source_foundation_baseline: SOURCE_FOUNDATION_BASELINE.to_owned(),
    210     }
    211 }
    212 
    213 #[cfg_attr(not(coverage_nightly), derive(uniffi::Error))]
    214 pub enum HarvestCircleError {
    215     Failure {
    216         code: WireErrorCode,
    217         category: WireErrorCategory,
    218         retryable: bool,
    219         recovery_action: WireRecoveryAction,
    220         correlation_id: Option<String>,
    221         safe_message: String,
    222     },
    223 }
    224 
    225 impl fmt::Debug for HarvestCircleError {
    226     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    227         match self {
    228             Self::Failure {
    229                 code,
    230                 category,
    231                 retryable,
    232                 recovery_action,
    233                 correlation_id,
    234                 ..
    235             } => formatter
    236                 .debug_struct("HarvestCircleError::Failure")
    237                 .field("code", code)
    238                 .field("category", category)
    239                 .field("retryable", retryable)
    240                 .field("recovery_action", recovery_action)
    241                 .field(
    242                     "correlation_id",
    243                     &correlation_id.as_ref().map(|_| "<redacted>"),
    244                 )
    245                 .field("safe_message", &"<redacted>")
    246                 .finish(),
    247         }
    248     }
    249 }
    250 
    251 impl Display for HarvestCircleError {
    252     fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    253         match self {
    254             Self::Failure { safe_message, .. } => formatter.write_str(safe_message),
    255         }
    256     }
    257 }
    258 
    259 impl std::error::Error for HarvestCircleError {}
    260 
    261 impl From<SafeError> for HarvestCircleError {
    262     fn from(error: SafeError) -> Self {
    263         let (category, retryable, recovery_action) = error_policy(error.code());
    264         Self::Failure {
    265             code: error.code().into(),
    266             category,
    267             retryable,
    268             recovery_action,
    269             correlation_id: None,
    270             safe_message: error.message().as_str().to_owned(),
    271         }
    272     }
    273 }
    274 
    275 impl HarvestCircleError {
    276     fn correlated(error: SafeError, correlation_id: &DurableRequestId) -> Self {
    277         let (category, retryable, recovery_action) = error_policy(error.code());
    278         Self::Failure {
    279             code: error.code().into(),
    280             category,
    281             retryable,
    282             recovery_action,
    283             correlation_id: Some(correlation_id.as_str().to_owned()),
    284             safe_message: error.message().as_str().to_owned(),
    285         }
    286     }
    287 }
    288 
    289 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
    290 pub struct GeneratedRecoveryRequest {
    291     handle: GeneratedKeyRecoveryHandle,
    292     resolution: AtomicU8,
    293 }
    294 
    295 const RECOVERY_PENDING: u8 = 0;
    296 const RECOVERY_RESOLVING: u8 = 1;
    297 const RECOVERY_RESOLVED: u8 = 2;
    298 
    299 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    300 impl GeneratedRecoveryRequest {
    301     pub fn identity(&self) -> IdentityDto {
    302         self.handle.view().identity().into()
    303     }
    304 
    305     pub fn expires_at_seconds(&self) -> i64 {
    306         self.handle.view().expires_at().as_seconds()
    307     }
    308 
    309     /// Returns the recovery secret exactly once.
    310     ///
    311     /// # Errors
    312     ///
    313     /// Returns a safe unavailable error after the first read.
    314     pub fn take_recovery_nsec(&self) -> Result<String, HarvestCircleError> {
    315         self.handle
    316             .take_recovery_nsec()
    317             .map(|nsec| nsec.with_exposed_secret(str::to_owned))
    318             .map_err(HarvestCircleError::from)
    319     }
    320 }
    321 
    322 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
    323 pub struct RemovalRequest {
    324     public_key_hex: String,
    325     deletes_local_credential: bool,
    326     signs_out: bool,
    327     expires_at_seconds: i64,
    328     token: Mutex<Option<RemovalConfirmationToken>>,
    329 }
    330 
    331 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    332 impl RemovalRequest {
    333     pub fn public_key_hex(&self) -> String {
    334         self.public_key_hex.clone()
    335     }
    336 
    337     pub fn deletes_local_credential(&self) -> bool {
    338         self.deletes_local_credential
    339     }
    340 
    341     pub fn signs_out(&self) -> bool {
    342         self.signs_out
    343     }
    344 
    345     pub fn expires_at_seconds(&self) -> i64 {
    346         self.expires_at_seconds
    347     }
    348 }
    349 
    350 pub(crate) struct RuntimeCore {
    351     pub(crate) actor: RuntimeActorHandle,
    352     pub(crate) runtime: tokio::runtime::Handle,
    353     pub(crate) host_runtime: Option<Arc<HostRuntime>>,
    354     pub(crate) keyring: Option<Arc<BoundedKeyringWorker>>,
    355     pub(crate) observers: Mutex<
    356         BTreeMap<
    357             harvestcircle_application::ChangeSubscriptionId,
    358             Arc<crate::observer::ObserverTask>,
    359         >,
    360     >,
    361     pub(crate) retired_observers: Mutex<
    362         BTreeMap<
    363             harvestcircle_application::ChangeSubscriptionId,
    364             Arc<crate::observer::ObserverTask>,
    365         >,
    366     >,
    367     pub(crate) observer_admission: Arc<tokio::sync::Semaphore>,
    368     pub(crate) close_state: AtomicU8,
    369     pub(crate) close_gate: tokio::sync::Mutex<()>,
    370     #[cfg(test)]
    371     pub(crate) _test_directory: Option<Arc<tempfile::TempDir>>,
    372 }
    373 
    374 impl RuntimeCore {
    375     pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto {
    376         AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle())
    377     }
    378 
    379     pub(crate) fn dto_for(
    380         &self,
    381         snapshot: &harvestcircle_application::AppSnapshot,
    382     ) -> AppSnapshotDto {
    383         AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle())
    384     }
    385 
    386     pub(crate) fn effective_lifecycle(&self) -> harvestcircle_application::RuntimeLifecycle {
    387         self.actor.lifecycle()
    388     }
    389 
    390     pub(crate) fn is_open(&self) -> bool {
    391         self.close_state.load(Ordering::Acquire) == 0
    392     }
    393 
    394     pub(crate) fn ensure_open(&self) -> Result<(), HarvestCircleError> {
    395         if self.is_open() {
    396             Ok(())
    397         } else {
    398             Err(runtime_closed_error())
    399         }
    400     }
    401 }
    402 
    403 #[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
    404 pub struct HarvestCircleAppCore {
    405     pub(crate) inner: Arc<RuntimeCore>,
    406 }
    407 
    408 #[cfg_attr(not(coverage_nightly), uniffi::export)]
    409 impl HarvestCircleAppCore {
    410     /// Verifies the static contract before touching the application data path.
    411     ///
    412     /// # Errors
    413     ///
    414     /// Returns a safe compatibility error without opening or migrating storage.
    415     #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
    416     #[allow(clippy::needless_pass_by_value)]
    417     pub fn open_compatible(
    418         expectation: CompatibilityExpectation,
    419         input: RuntimeOpenInputDto,
    420     ) -> Result<Arc<Self>, HarvestCircleError> {
    421         verify_compatibility(&expectation)?;
    422         let relays = validated_relay_configuration(&input.relay_input)?;
    423         let context = application_runtime_context(&input)?;
    424         Self::open_context(&context, relays)
    425     }
    426 
    427     /// Restores durable public application state.
    428     ///
    429     /// # Errors
    430     ///
    431     /// Returns a safe storage, recovery, or application-state error.
    432     pub async fn bootstrap(&self) -> Result<AppSnapshotDto, HarvestCircleError> {
    433         self.inner.ensure_open()?;
    434         self.inner
    435             .actor
    436             .bootstrap()
    437             .await
    438             .map(|snapshot| self.inner.dto_for(&snapshot))
    439             .map_err(HarvestCircleError::from)
    440     }
    441 
    442     #[must_use]
    443     pub fn snapshot(&self) -> AppSnapshotDto {
    444         self.inner.snapshot_dto()
    445     }
    446 
    447     /// Begins the exclusive generated-identity recovery flow without persistence.
    448     ///
    449     /// # Errors
    450     ///
    451     /// Returns a safe key-generation, conflict, timeout, or lifecycle error.
    452     pub async fn begin_generated_identity(
    453         &self,
    454     ) -> Result<Arc<GeneratedRecoveryRequest>, HarvestCircleError> {
    455         self.inner.ensure_open()?;
    456         self.inner
    457             .actor
    458             .begin_generated_key_stage()
    459             .await
    460             .map(|handle| {
    461                 Arc::new(GeneratedRecoveryRequest {
    462                     handle,
    463                     resolution: AtomicU8::new(RECOVERY_PENDING),
    464                 })
    465             })
    466             .map_err(HarvestCircleError::from)
    467     }
    468 
    469     /// Acknowledges recovery and commits the generated identity once.
    470     ///
    471     /// # Errors
    472     ///
    473     /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error.
    474     /// A failed commit must be recovered by importing the already-saved recovery key.
    475     pub async fn acknowledge_generated_identity(
    476         &self,
    477         context: RequestContextDto,
    478         request: Arc<GeneratedRecoveryRequest>,
    479     ) -> Result<AppSnapshotDto, HarvestCircleError> {
    480         self.inner.ensure_open()?;
    481         let (request_id, timeout) = validate_request_context(&context)?;
    482         if request
    483             .resolution
    484             .compare_exchange(
    485                 RECOVERY_PENDING,
    486                 RECOVERY_RESOLVING,
    487                 Ordering::AcqRel,
    488                 Ordering::Acquire,
    489             )
    490             .is_err()
    491         {
    492             return Err(generated_recovery_expired());
    493         }
    494         let result = self
    495             .inner
    496             .actor
    497             .acknowledge_generated_key_stage(
    498                 request.handle.id(),
    499                 request_id,
    500                 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision),
    501                 timeout,
    502             )
    503             .await;
    504         request
    505             .resolution
    506             .store(RECOVERY_RESOLVED, Ordering::Release);
    507         result
    508             .map(|snapshot| self.inner.dto_for(&snapshot))
    509             .map_err(generated_commit_failed)
    510     }
    511 
    512     /// Cancels the exclusive generated-identity recovery flow.
    513     ///
    514     /// # Errors
    515     ///
    516     /// Returns a safe timeout or lifecycle error.
    517     pub async fn cancel_generated_identity(
    518         &self,
    519         request: Arc<GeneratedRecoveryRequest>,
    520     ) -> Result<bool, HarvestCircleError> {
    521         self.inner.ensure_open()?;
    522         if request
    523             .resolution
    524             .compare_exchange(
    525                 RECOVERY_PENDING,
    526                 RECOVERY_RESOLVING,
    527                 Ordering::AcqRel,
    528                 Ordering::Acquire,
    529             )
    530             .is_err()
    531         {
    532             return Ok(false);
    533         }
    534         let result = self.inner.actor.cancel_generated_key_stage().await;
    535         request
    536             .resolution
    537             .store(RECOVERY_RESOLVED, Ordering::Release);
    538         result.map_err(HarvestCircleError::from)
    539     }
    540 
    541     /// Imports or repairs an identity using a caller-owned idempotency key.
    542     ///
    543     /// # Errors
    544     ///
    545     /// Returns a correlated validation, conflict, timeout, credential, or storage error.
    546     pub async fn import_identity(
    547         &self,
    548         context: RequestContextDto,
    549         secret_key: Vec<u8>,
    550     ) -> Result<IdentityCommandReceiptDto, HarvestCircleError> {
    551         self.inner.ensure_open()?;
    552         let (request_id, timeout) = validate_request_context(&context)?;
    553         let input = SecretKeyInput::parse_bytes(secret_key)
    554             .map_err(|error| HarvestCircleError::correlated(error, &request_id))?;
    555         self.inner
    556             .actor
    557             .import_secret_key(
    558                 request_id.clone(),
    559                 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision),
    560                 input,
    561                 timeout,
    562             )
    563             .await
    564             .map(|_| {
    565                 let snapshot = self.inner.snapshot_dto();
    566                 IdentityCommandReceiptDto {
    567                     request_id: context.request_id.clone(),
    568                     committed_revision: snapshot.revision,
    569                     snapshot,
    570                 }
    571             })
    572             .map_err(|error| HarvestCircleError::correlated(error, &request_id))
    573     }
    574 
    575     /// Selects one saved identity without activating it.
    576     ///
    577     /// # Errors
    578     ///
    579     /// Returns a safe public-key, identity, or storage error.
    580     pub async fn select_identity(
    581         &self,
    582         public_key_hex: String,
    583     ) -> Result<AppSnapshotDto, HarvestCircleError> {
    584         self.inner.ensure_open()?;
    585         let public_key = parse_public_key(&public_key_hex)?;
    586         self.inner
    587             .actor
    588             .select_identity(public_key)
    589             .await
    590             .map(|snapshot| self.inner.dto_for(&snapshot))
    591             .map_err(HarvestCircleError::from)
    592     }
    593 
    594     /// Activates one saved identity after validating its credential.
    595     ///
    596     /// # Errors
    597     ///
    598     /// Returns a safe public-key, credential, identity, or storage error.
    599     pub async fn activate_identity(
    600         &self,
    601         public_key_hex: String,
    602     ) -> Result<AppSnapshotDto, HarvestCircleError> {
    603         self.inner.ensure_open()?;
    604         let public_key = parse_public_key(&public_key_hex)?;
    605         self.inner
    606             .actor
    607             .activate_identity(public_key)
    608             .await
    609             .map(|snapshot| self.inner.dto_for(&snapshot))
    610             .map_err(HarvestCircleError::from)
    611     }
    612 
    613     /// Signs out while retaining identities and credentials.
    614     ///
    615     /// # Errors
    616     ///
    617     /// Returns a safe application-state error.
    618     pub async fn sign_out(&self) -> Result<AppSnapshotDto, HarvestCircleError> {
    619         self.inner.ensure_open()?;
    620         self.inner
    621             .actor
    622             .sign_out()
    623             .await
    624             .map(|snapshot| self.inner.dto_for(&snapshot))
    625             .map_err(HarvestCircleError::from)
    626     }
    627 
    628     /// Refreshes the active Nostr profile from configured relays.
    629     ///
    630     /// # Errors
    631     ///
    632     /// Returns a safe storage or application-state error.
    633     pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, HarvestCircleError> {
    634         self.inner.ensure_open()?;
    635         self.inner
    636             .actor
    637             .refresh_active_profile()
    638             .await
    639             .map(|snapshot| self.inner.dto_for(&snapshot))
    640             .map_err(HarvestCircleError::from)
    641     }
    642 
    643     /// Issues a revision-bound removal confirmation object.
    644     ///
    645     /// # Errors
    646     ///
    647     /// Returns a safe public-key or identity error.
    648     pub async fn request_identity_removal(
    649         &self,
    650         public_key_hex: String,
    651     ) -> Result<Arc<RemovalRequest>, HarvestCircleError> {
    652         self.inner.ensure_open()?;
    653         let public_key = parse_public_key(&public_key_hex)?;
    654         self.inner
    655             .actor
    656             .request_identity_removal(public_key)
    657             .await
    658             .map(|token| {
    659                 let impact = token.impact();
    660                 Arc::new(RemovalRequest {
    661                     public_key_hex,
    662                     deletes_local_credential: impact.deletes_local_credential(),
    663                     signs_out: impact.signs_out(),
    664                     expires_at_seconds: token.expires_at().as_seconds(),
    665                     token: Mutex::new(Some(token)),
    666                 })
    667             })
    668             .map_err(HarvestCircleError::from)
    669     }
    670 
    671     /// Permanently removes the identity represented by a one-time request.
    672     ///
    673     /// # Errors
    674     ///
    675     /// Returns a safe confirmation, credential, recovery, or storage error.
    676     pub async fn confirm_identity_removal(
    677         &self,
    678         context: RequestContextDto,
    679         request: Arc<RemovalRequest>,
    680     ) -> Result<AppSnapshotDto, HarvestCircleError> {
    681         self.inner.ensure_open()?;
    682         let (request_id, timeout) = validate_request_context(&context)?;
    683         let token = request
    684             .token
    685             .lock()
    686             .map_err(|_| internal_state_unavailable())?
    687             .take()
    688             .ok_or_else(confirmation_expired)?;
    689         self.inner
    690             .actor
    691             .confirm_identity_removal(
    692                 token,
    693                 request_id.clone(),
    694                 harvestcircle_application::SnapshotRevision::from_value(context.expected_revision),
    695                 timeout,
    696             )
    697             .await
    698             .map(|snapshot| self.inner.dto_for(&snapshot))
    699             .map_err(|error| HarvestCircleError::correlated(error, &request_id))
    700     }
    701 }
    702 
    703 fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), HarvestCircleError> {
    704     let actual = compatibility_descriptor();
    705     if expectation.contract_id != actual.contract_id
    706         || expectation.contract_major != actual.contract_major
    707         || expectation.minimum_contract_minor > actual.contract_minor
    708         || expectation.contract_hash != actual.contract_hash
    709         || expectation.product_coordinate_digest != actual.product_coordinate_digest
    710         || expectation.snapshot_schema_version != actual.snapshot_schema_version
    711         || expectation.minimum_schema_version > actual.current_schema_version
    712         || expectation.maximum_schema_version < actual.minimum_schema_version
    713     {
    714         return Err(compatibility_mismatch());
    715     }
    716     Ok(())
    717 }
    718 
    719 impl HarvestCircleAppCore {
    720     #[cfg(test)]
    721     fn open_context_compatible(
    722         context: &RuntimeContext,
    723         expectation: &CompatibilityExpectation,
    724         relay_input: RelayBootstrapInputDto,
    725     ) -> Result<Arc<Self>, HarvestCircleError> {
    726         verify_compatibility(expectation)?;
    727         let relays = validated_relay_configuration(&relay_input)?;
    728         Self::open_context(context, relays)
    729     }
    730 
    731     // The concrete product opener binds operating-system paths, keyrings, and
    732     // SQLite ownership. Platform installation lanes exercise this adapter;
    733     // deterministic coverage owns the compatibility and runtime policies.
    734     #[cfg_attr(coverage_nightly, coverage(off))]
    735     fn open_context(
    736         context: &RuntimeContext,
    737         relays: RelayConfiguration,
    738     ) -> Result<Arc<Self>, HarvestCircleError> {
    739         let runtime = HostRuntime::new().map_err(|()| runtime_unavailable())?;
    740         let runtime_handle = runtime.handle().clone();
    741         let keyring = BoundedKeyringWorker::new(OsKeyringSecretStore::default())
    742             .map_err(HarvestCircleError::from)?;
    743         let secrets: Arc<dyn SecretStore> = keyring.clone();
    744         let build = migration_build_identity()?;
    745         let actor_capacity = actor_mailbox_capacity()?;
    746         let owned_context = context.clone();
    747         let actor_runtime = runtime_handle.clone();
    748         let actor = runtime
    749             .block_on(async move {
    750                 RuntimeActorHandle::open(
    751                     &owned_context,
    752                     relays,
    753                     RuntimeDependencies::new(
    754                         secrets,
    755                         Arc::new(SystemClock),
    756                         Arc::new(SdkNostrClient::new(Duration::from_secs(5))),
    757                         Arc::new(UuidInstallationIdentitySource),
    758                     ),
    759                     &build,
    760                     actor_capacity,
    761                     &actor_runtime,
    762                 )
    763                 .await
    764             })
    765             .map_err(|()| runtime_unavailable())??;
    766         Ok(Arc::new(Self {
    767             inner: Arc::new(RuntimeCore {
    768                 actor,
    769                 runtime: runtime_handle,
    770                 host_runtime: Some(runtime),
    771                 keyring: Some(keyring),
    772                 observers: Mutex::new(BTreeMap::new()),
    773                 retired_observers: Mutex::new(BTreeMap::new()),
    774                 observer_admission: Arc::new(tokio::sync::Semaphore::new(
    775                     crate::observer::MAX_OBSERVERS,
    776                 )),
    777                 close_state: AtomicU8::new(0),
    778                 close_gate: tokio::sync::Mutex::new(()),
    779                 #[cfg(test)]
    780                 _test_directory: None,
    781             }),
    782         }))
    783     }
    784 }
    785 
    786 fn validated_relay_configuration(
    787     relay_input: &RelayBootstrapInputDto,
    788 ) -> Result<RelayConfiguration, HarvestCircleError> {
    789     if relay_input.endpoints.len() > MAX_CONFIGURED_RELAYS {
    790         return Err(invalid_relay_configuration());
    791     }
    792     let relay_endpoints = relay_input
    793         .endpoints
    794         .iter()
    795         .map(|endpoint| {
    796             RelayEndpointInput::new(
    797                 endpoint.url.clone(),
    798                 match endpoint.destination {
    799                     RelayDestinationDto::Local => RelayUrlPolicy::Local,
    800                     RelayDestinationDto::PrivateNetwork => RelayUrlPolicy::PrivateNetwork,
    801                     RelayDestinationDto::Public => RelayUrlPolicy::Public,
    802                 },
    803                 endpoint.read,
    804                 endpoint.write,
    805             )
    806         })
    807         .collect::<Vec<_>>();
    808     relay_configuration_from_endpoints(&relay_endpoints).map_err(HarvestCircleError::from)
    809 }
    810 
    811 #[derive(Clone, Copy)]
    812 pub(crate) struct SystemClock;
    813 
    814 impl Clock for SystemClock {
    815     fn now(&self) -> UnixTimestamp {
    816         let seconds = SystemTime::now()
    817             .duration_since(UNIX_EPOCH)
    818             .map_or(0, |duration| {
    819                 i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
    820             });
    821         UnixTimestamp::from_seconds(seconds).unwrap_or(UnixTimestamp::UNIX_EPOCH)
    822     }
    823 }
    824 
    825 // BaseDirs is the production host integration boundary. Development roots are
    826 // supplied explicitly by the desktop host and runtime_paths receives only
    827 // validated injected values.
    828 #[cfg_attr(coverage_nightly, coverage(off))]
    829 fn application_runtime_context(
    830     input: &RuntimeOpenInputDto,
    831 ) -> Result<RuntimeContext, HarvestCircleError> {
    832     let (profile, root, environment, profile_source) =
    833         if let Some(raw_directory) = input.explicit_data_directory.as_deref() {
    834             if !input.development_mode || raw_directory.is_empty() {
    835                 return Err(path_unavailable());
    836             }
    837             let directory = PathBuf::from(raw_directory);
    838             if !directory.is_absolute() {
    839                 return Err(path_unavailable());
    840             }
    841             let metadata = std::fs::symlink_metadata(&directory).map_err(|_| path_unavailable())?;
    842             if metadata.file_type().is_symlink() || !metadata.is_dir() {
    843                 return Err(path_unavailable());
    844             }
    845             let canonical = std::fs::canonicalize(&directory).map_err(|_| path_unavailable())?;
    846             if canonical != directory {
    847                 return Err(path_unavailable());
    848             }
    849             (
    850                 RadrootsPathProfile::RepoLocal,
    851                 Some(canonical),
    852                 RadrootsHostEnvironment::default(),
    853                 RuntimeContextSource::BootstrapCli,
    854             )
    855         } else {
    856             let base = BaseDirs::new().ok_or_else(path_unavailable)?;
    857             (
    858                 RadrootsPathProfile::InteractiveUser,
    859                 None,
    860                 RadrootsHostEnvironment {
    861                     home_dir: Some(base.home_dir().to_path_buf()),
    862                     xdg_config_home: Some(base.config_dir().to_path_buf()),
    863                     xdg_data_home: Some(base.data_dir().to_path_buf()),
    864                     xdg_state_home: base.state_dir().map(Path::to_path_buf),
    865                     xdg_cache_home: Some(base.cache_dir().to_path_buf()),
    866                     xdg_runtime_dir: base.runtime_dir().map(Path::to_path_buf),
    867                     appdata_dir: None,
    868                     localappdata_dir: None,
    869                 },
    870                 RuntimeContextSource::SafeDefault,
    871             )
    872         };
    873     let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), environment);
    874     let bootstrap = RuntimeContextBootstrap::new(
    875         profile,
    876         root,
    877         profile_source,
    878         RuntimeContextSource::SafeDefault,
    879     )
    880     .map_err(|_| path_unavailable())?;
    881     RuntimeContext::resolve(
    882         &resolver,
    883         bootstrap,
    884         ServiceId::new("harvestcircle").map_err(|_| path_unavailable())?,
    885         InstanceId::new("desktop").map_err(|_| path_unavailable())?,
    886     )
    887     .map_err(|_| path_unavailable())
    888 }
    889 
    890 fn migration_build_identity() -> Result<MigrationBuildIdentity, HarvestCircleError> {
    891     MigrationBuildIdentity::new(
    892         PRODUCT_VERSION,
    893         BUILD_SOURCE_COMMIT,
    894         BUILD_RADROOTS_REVISION,
    895         BUILD_RUST_TOOLCHAIN,
    896         format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
    897         "desktop",
    898         1,
    899         1,
    900         1,
    901         1,
    902         1,
    903     )
    904     .map_err(|_| path_unavailable())
    905 }
    906 
    907 fn parse_public_key(value: &str) -> Result<PublicKey, HarvestCircleError> {
    908     PublicKey::from_hex(value).map_err(HarvestCircleError::from)
    909 }
    910 
    911 fn validate_request_context(
    912     context: &RequestContextDto,
    913 ) -> Result<(DurableRequestId, Duration), HarvestCircleError> {
    914     let request_id =
    915         DurableRequestId::parse(&context.request_id).map_err(HarvestCircleError::from)?;
    916     let timeout = command_timeout(context.deadline_millis, &request_id)?;
    917     Ok((request_id, timeout))
    918 }
    919 
    920 fn command_timeout(
    921     millis: u64,
    922     correlation_id: &DurableRequestId,
    923 ) -> Result<Duration, HarvestCircleError> {
    924     if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS {
    925         return Err(HarvestCircleError::Failure {
    926             code: WireErrorCode::InvalidApplicationState,
    927             category: WireErrorCategory::Input,
    928             retryable: false,
    929             recovery_action: WireRecoveryAction::None,
    930             correlation_id: Some(correlation_id.as_str().to_owned()),
    931             safe_message: "The command deadline is invalid.".to_owned(),
    932         });
    933     }
    934     Ok(Duration::from_millis(millis))
    935 }
    936 
    937 #[cfg(test)]
    938 pub(crate) async fn test_actor(
    939     relays: RelayConfiguration,
    940 ) -> (RuntimeActorHandle, Arc<tempfile::TempDir>) {
    941     test_actor_with_nostr_timeout(relays, Duration::from_millis(10)).await
    942 }
    943 
    944 #[cfg(test)]
    945 pub(crate) async fn test_actor_with_nostr_timeout(
    946     relays: RelayConfiguration,
    947     nostr_timeout: Duration,
    948 ) -> (RuntimeActorHandle, Arc<tempfile::TempDir>) {
    949     let directory = Arc::new(tempfile::tempdir().expect("temporary runtime root"));
    950     let context = application_runtime_context(&RuntimeOpenInputDto {
    951         development_mode: true,
    952         explicit_data_directory: Some(
    953             directory
    954                 .path()
    955                 .canonicalize()
    956                 .expect("canonical runtime root")
    957                 .to_string_lossy()
    958                 .into_owned(),
    959         ),
    960         relay_input: RelayBootstrapInputDto {
    961             endpoints: Vec::new(),
    962         },
    963     })
    964     .expect("runtime context");
    965     std::fs::create_dir_all(directory.path().join("data")).expect("state root");
    966     let build = migration_build_identity().expect("migration build identity");
    967     let actor = RuntimeActorHandle::open(
    968         &context,
    969         relays,
    970         RuntimeDependencies::new(
    971             Arc::new(harvestcircle_application::InMemorySecretStore::default()),
    972             Arc::new(SystemClock),
    973             Arc::new(SdkNostrClient::new(nostr_timeout)),
    974             Arc::new(UuidInstallationIdentitySource),
    975         ),
    976         &build,
    977         actor_mailbox_capacity().expect("capacity"),
    978         &tokio::runtime::Handle::current(),
    979     )
    980     .await
    981     .expect("test actor");
    982     (actor, directory)
    983 }
    984 
    985 fn actor_mailbox_capacity() -> Result<NonZeroUsize, HarvestCircleError> {
    986     NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable)
    987 }
    988 
    989 fn runtime_unavailable() -> HarvestCircleError {
    990     HarvestCircleError::Failure {
    991         code: WireErrorCode::InvalidApplicationState,
    992         category: WireErrorCategory::Lifecycle,
    993         retryable: true,
    994         recovery_action: WireRecoveryAction::RestartApplication,
    995         correlation_id: None,
    996         safe_message: "The application runtime is unavailable.".to_owned(),
    997     }
    998 }
    999 
   1000 pub(crate) fn internal_state_unavailable() -> HarvestCircleError {
   1001     HarvestCircleError::Failure {
   1002         code: WireErrorCode::Internal,
   1003         category: WireErrorCategory::Internal,
   1004         retryable: false,
   1005         recovery_action: WireRecoveryAction::RestartApplication,
   1006         correlation_id: None,
   1007         safe_message: "The application state is unavailable.".to_owned(),
   1008     }
   1009 }
   1010 
   1011 pub(crate) fn runtime_closed_error() -> HarvestCircleError {
   1012     HarvestCircleError::Failure {
   1013         code: WireErrorCode::InvalidApplicationState,
   1014         category: WireErrorCategory::Lifecycle,
   1015         retryable: false,
   1016         recovery_action: WireRecoveryAction::None,
   1017         correlation_id: None,
   1018         safe_message: "The application runtime is closed.".to_owned(),
   1019     }
   1020 }
   1021 
   1022 fn invalid_relay_configuration() -> HarvestCircleError {
   1023     HarvestCircleError::from(SafeError::new(
   1024         SafeErrorCode::InvalidRelayConfiguration,
   1025         SafeMessage::new("The Nostr relay configuration is invalid."),
   1026     ))
   1027 }
   1028 
   1029 fn path_unavailable() -> HarvestCircleError {
   1030     HarvestCircleError::Failure {
   1031         code: WireErrorCode::StorageUnavailable,
   1032         category: WireErrorCategory::Storage,
   1033         retryable: true,
   1034         recovery_action: WireRecoveryAction::RestartApplication,
   1035         correlation_id: None,
   1036         safe_message: "The application data directory is unavailable.".to_owned(),
   1037     }
   1038 }
   1039 
   1040 fn confirmation_expired() -> HarvestCircleError {
   1041     HarvestCircleError::Failure {
   1042         code: WireErrorCode::InvalidApplicationState,
   1043         category: WireErrorCategory::Lifecycle,
   1044         retryable: false,
   1045         recovery_action: WireRecoveryAction::None,
   1046         correlation_id: None,
   1047         safe_message: "The identity removal confirmation is no longer valid.".to_owned(),
   1048     }
   1049 }
   1050 
   1051 fn generated_recovery_expired() -> HarvestCircleError {
   1052     HarvestCircleError::Failure {
   1053         code: WireErrorCode::InvalidApplicationState,
   1054         category: WireErrorCategory::Lifecycle,
   1055         retryable: false,
   1056         recovery_action: WireRecoveryAction::None,
   1057         correlation_id: None,
   1058         safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
   1059     }
   1060 }
   1061 
   1062 fn generated_commit_failed(error: SafeError) -> HarvestCircleError {
   1063     let (category, _, _) = error_policy(error.code());
   1064     HarvestCircleError::Failure {
   1065         code: error.code().into(),
   1066         category,
   1067         retryable: false,
   1068         recovery_action: WireRecoveryAction::None,
   1069         correlation_id: None,
   1070         safe_message:
   1071             "The generated identity could not be saved. Import the recovery key you saved to try again."
   1072                 .to_owned(),
   1073     }
   1074 }
   1075 
   1076 fn compatibility_mismatch() -> HarvestCircleError {
   1077     HarvestCircleError::Failure {
   1078         code: WireErrorCode::CompatibilityMismatch,
   1079         category: WireErrorCategory::Compatibility,
   1080         retryable: false,
   1081         recovery_action: WireRecoveryAction::UpdateApplication,
   1082         correlation_id: None,
   1083         safe_message: "The application and native runtime are incompatible.".to_owned(),
   1084     }
   1085 }
   1086 
   1087 #[cfg(test)]
   1088 #[cfg_attr(coverage_nightly, coverage(off))]
   1089 mod tests {
   1090     use std::error::Error as _;
   1091     use std::sync::Arc;
   1092 
   1093     use harvestcircle_application::{
   1094         RelayConfiguration, RelayEndpointInput, RelayUrlPolicy, relay_configuration_from_endpoints,
   1095     };
   1096     use harvestcircle_domain::SafeError;
   1097     use harvestcircle_storage::{
   1098         CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION, HarvestCircleStorageContract,
   1099     };
   1100 
   1101     use super::{
   1102         CompatibilityExpectation, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR,
   1103         FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, MAX_CONFIGURED_RELAYS,
   1104         PRODUCT_COORDINATE_DIGEST, RelayBootstrapInputDto, RelayDestinationDto, RelayEndpointDto,
   1105         RequestContextDto, RuntimeCore, RuntimeOpenInputDto, SNAPSHOT_SCHEMA_VERSION,
   1106         WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity,
   1107         application_runtime_context, compatibility_descriptor, confirmation_expired,
   1108         generated_commit_failed, path_unavailable, runtime_unavailable, test_actor,
   1109         verify_compatibility,
   1110     };
   1111 
   1112     async fn in_memory_core() -> Arc<HarvestCircleAppCore> {
   1113         let (actor, directory) = test_actor(RelayConfiguration::default()).await;
   1114         Arc::new(HarvestCircleAppCore {
   1115             inner: Arc::new(RuntimeCore {
   1116                 actor,
   1117                 runtime: tokio::runtime::Handle::current(),
   1118                 host_runtime: None,
   1119                 keyring: None,
   1120                 observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
   1121                 retired_observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
   1122                 observer_admission: Arc::new(tokio::sync::Semaphore::new(
   1123                     crate::observer::MAX_OBSERVERS,
   1124                 )),
   1125                 close_state: std::sync::atomic::AtomicU8::new(0),
   1126                 close_gate: tokio::sync::Mutex::new(()),
   1127                 _test_directory: Some(directory),
   1128             }),
   1129         })
   1130     }
   1131 
   1132     #[tokio::test]
   1133     async fn exported_bootstrap_and_snapshot_are_revisioned() {
   1134         let core = in_memory_core().await;
   1135         let bootstrapped = core.bootstrap().await.expect("bootstrap");
   1136         let current = core.snapshot();
   1137 
   1138         assert_eq!(bootstrapped, current);
   1139         assert_eq!(current.revision, 1);
   1140     }
   1141 
   1142     #[tokio::test]
   1143     async fn request_context_import_replays_one_committed_receipt() {
   1144         let core = in_memory_core().await;
   1145         let initial = core.snapshot();
   1146         let context = RequestContextDto {
   1147             request_id: "01890f3e-7b1c-7000-8000-000000000041".to_owned(),
   1148             expected_revision: initial.revision,
   1149             deadline_millis: 5_000,
   1150         };
   1151         let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
   1152         let first = core
   1153             .import_identity(context.clone(), secret.to_vec())
   1154             .await
   1155             .expect("first import");
   1156         let replay = core
   1157             .import_identity(context, secret.to_vec())
   1158             .await
   1159             .expect("replayed import");
   1160 
   1161         assert_eq!(first, replay);
   1162         assert_eq!(first.snapshot.identities.len(), 1);
   1163         assert_eq!(first.request_id, "01890f3e-7b1c-7000-8000-000000000041");
   1164     }
   1165 
   1166     #[tokio::test]
   1167     async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() {
   1168         let core = in_memory_core().await;
   1169         let initial = core.snapshot();
   1170         let recovery = core
   1171             .begin_generated_identity()
   1172             .await
   1173             .expect("begin recovery");
   1174 
   1175         assert_eq!(core.snapshot(), initial);
   1176         let nsec = recovery.take_recovery_nsec().expect("one-use nsec");
   1177         assert!(nsec.starts_with("nsec1"));
   1178         assert!(recovery.take_recovery_nsec().is_err());
   1179         let context = RequestContextDto {
   1180             request_id: "01890f3e-7b1c-7000-8000-000000000042".to_owned(),
   1181             expected_revision: initial.revision,
   1182             deadline_millis: 5_000,
   1183         };
   1184         let committed = core
   1185             .acknowledge_generated_identity(context.clone(), Arc::clone(&recovery))
   1186             .await
   1187             .expect("acknowledge");
   1188         assert_eq!(committed.identities.len(), 1);
   1189         let repeated = core
   1190             .acknowledge_generated_identity(context, recovery)
   1191             .await
   1192             .expect_err("repeated acknowledgement");
   1193         assert!(matches!(
   1194             repeated,
   1195             HarvestCircleError::Failure { safe_message, .. }
   1196                 if safe_message == "The generated-key recovery step is no longer valid."
   1197         ));
   1198     }
   1199 
   1200     #[tokio::test]
   1201     async fn identity_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() {
   1202         let core = in_memory_core().await;
   1203         let initial = core.bootstrap().await.expect("bootstrap");
   1204         let imported = core
   1205             .import_identity(
   1206                 RequestContextDto {
   1207                     request_id: "01890f3e-7b1c-7000-8000-000000000043".to_owned(),
   1208                     expected_revision: initial.revision,
   1209                     deadline_millis: 5_000,
   1210                 },
   1211                 b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(),
   1212             )
   1213             .await
   1214             .expect("import identity");
   1215         let public_key = imported.snapshot.identities[0].public_key_hex.clone();
   1216 
   1217         let selected = core
   1218             .select_identity(public_key.clone())
   1219             .await
   1220             .expect("select identity");
   1221         let active = core
   1222             .activate_identity(public_key.clone())
   1223             .await
   1224             .expect("activate identity");
   1225         assert!(active.revision > selected.revision);
   1226         let signed_out = core.sign_out().await.expect("sign out");
   1227         assert!(signed_out.revision > active.revision);
   1228         let refreshed = core
   1229             .refresh_active_profile()
   1230             .await
   1231             .expect("signed-out refresh is a stable no-op");
   1232         assert_eq!(refreshed.revision, signed_out.revision);
   1233 
   1234         let removal = core
   1235             .request_identity_removal(public_key.clone())
   1236             .await
   1237             .expect("request removal");
   1238         assert_eq!(removal.public_key_hex(), public_key);
   1239         assert!(removal.deletes_local_credential());
   1240         assert!(!removal.signs_out());
   1241         assert!(removal.expires_at_seconds() > 0);
   1242         let invalid_confirmation = core
   1243             .confirm_identity_removal(
   1244                 RequestContextDto {
   1245                     request_id: "secret-invalid-request".to_owned(),
   1246                     expected_revision: signed_out.revision,
   1247                     deadline_millis: 5_000,
   1248                 },
   1249                 Arc::clone(&removal),
   1250             )
   1251             .await
   1252             .expect_err("invalid request context");
   1253         assert!(matches!(
   1254             invalid_confirmation,
   1255             HarvestCircleError::Failure {
   1256                 correlation_id: None,
   1257                 ..
   1258             }
   1259         ));
   1260         assert_eq!(core.snapshot().identities.len(), 1);
   1261         let removed = core
   1262             .confirm_identity_removal(
   1263                 RequestContextDto {
   1264                     request_id: "01890f3e-7b1c-7000-8000-000000000044".to_owned(),
   1265                     expected_revision: signed_out.revision,
   1266                     deadline_millis: 5_000,
   1267                 },
   1268                 Arc::clone(&removal),
   1269             )
   1270             .await
   1271             .expect("confirm removal");
   1272         assert!(removed.identities.is_empty());
   1273         assert!(
   1274             core.confirm_identity_removal(
   1275                 RequestContextDto {
   1276                     request_id: "01890f3e-7b1c-7000-8000-000000000045".to_owned(),
   1277                     expected_revision: removed.revision,
   1278                     deadline_millis: 5_000,
   1279                 },
   1280                 removal,
   1281             )
   1282             .await
   1283             .is_err()
   1284         );
   1285         assert!(
   1286             core.select_identity("not-a-public-key".to_owned())
   1287                 .await
   1288                 .is_err()
   1289         );
   1290     }
   1291 
   1292     #[tokio::test]
   1293     async fn generated_recovery_cancellation_and_request_validation_fail_closed() {
   1294         let core = in_memory_core().await;
   1295         let recovery = core
   1296             .begin_generated_identity()
   1297             .await
   1298             .expect("begin generated identity");
   1299         assert_eq!(recovery.identity().public_key_hex.len(), 64);
   1300         assert!(recovery.expires_at_seconds() > 0);
   1301         assert!(
   1302             core.cancel_generated_identity(Arc::clone(&recovery))
   1303                 .await
   1304                 .expect("first cancellation")
   1305         );
   1306         assert!(
   1307             !core
   1308                 .cancel_generated_identity(recovery)
   1309                 .await
   1310                 .expect("second cancellation")
   1311         );
   1312 
   1313         for context in [
   1314             RequestContextDto {
   1315                 request_id: String::new(),
   1316                 expected_revision: 0,
   1317                 deadline_millis: 5_000,
   1318             },
   1319             RequestContextDto {
   1320                 request_id: "01890f3e-7b1c-7000-8000-000000000046".to_owned(),
   1321                 expected_revision: 0,
   1322                 deadline_millis: 0,
   1323             },
   1324             RequestContextDto {
   1325                 request_id: "01890f3e-7b1c-7000-8000-000000000047".to_owned(),
   1326                 expected_revision: 0,
   1327                 deadline_millis: 30_001,
   1328             },
   1329         ] {
   1330             assert!(core.import_identity(context, vec![0; 32]).await.is_err());
   1331         }
   1332         assert!(
   1333             core.import_identity(
   1334                 RequestContextDto {
   1335                     request_id: "01890f3e-7b1c-7000-8000-000000000048".to_owned(),
   1336                     expected_revision: 0,
   1337                     deadline_millis: 5_000,
   1338                 },
   1339                 vec![0; 31],
   1340             )
   1341             .await
   1342             .is_err()
   1343         );
   1344 
   1345         let recovery = core
   1346             .begin_generated_identity()
   1347             .await
   1348             .expect("begin after validation failures");
   1349         let invalid = core
   1350             .acknowledge_generated_identity(
   1351                 RequestContextDto {
   1352                     request_id: "not-a-valid-request-id".to_owned(),
   1353                     expected_revision: core.snapshot().revision,
   1354                     deadline_millis: 5_000,
   1355                 },
   1356                 Arc::clone(&recovery),
   1357             )
   1358             .await
   1359             .expect_err("invalid request");
   1360         assert!(matches!(
   1361             invalid,
   1362             HarvestCircleError::Failure {
   1363                 correlation_id: None,
   1364                 ..
   1365             }
   1366         ));
   1367         core.acknowledge_generated_identity(
   1368             RequestContextDto {
   1369                 request_id: "01890f3e-7b1c-7000-8000-000000000049".to_owned(),
   1370                 expected_revision: core.snapshot().revision,
   1371                 deadline_millis: 5_000,
   1372             },
   1373             recovery,
   1374         )
   1375         .await
   1376         .expect("valid retry retains one-shot recovery");
   1377     }
   1378 
   1379     #[test]
   1380     fn input_and_error_debug_are_type_safe_and_redacted() {
   1381         let request_secret = "01890f3e-7b1c-7000-8000-00000000dead";
   1382         let path_secret = "/Users/private/secret-data";
   1383         let relay_secret = "wss://user:secret@example.invalid/private";
   1384         let request = RequestContextDto {
   1385             request_id: request_secret.to_owned(),
   1386             expected_revision: 9,
   1387             deadline_millis: 1_000,
   1388         };
   1389         let relay = crate::RelayEndpointDto {
   1390             url: relay_secret.to_owned(),
   1391             destination: crate::RelayDestinationDto::PrivateNetwork,
   1392             read: true,
   1393             write: true,
   1394         };
   1395         let open = RuntimeOpenInputDto {
   1396             development_mode: true,
   1397             explicit_data_directory: Some(path_secret.to_owned()),
   1398             relay_input: RelayBootstrapInputDto {
   1399                 endpoints: vec![relay.clone()],
   1400             },
   1401         };
   1402         let error = HarvestCircleError::Failure {
   1403             code: WireErrorCode::Internal,
   1404             category: WireErrorCategory::Internal,
   1405             retryable: false,
   1406             recovery_action: WireRecoveryAction::RestartApplication,
   1407             correlation_id: Some(request_secret.to_owned()),
   1408             safe_message: "A safe public message.".to_owned(),
   1409         };
   1410         let rendered = format!("{request:?} {relay:?} {open:?} {error:?}");
   1411         for secret in [
   1412             request_secret,
   1413             path_secret,
   1414             relay_secret,
   1415             "A safe public message.",
   1416         ] {
   1417             assert!(!rendered.contains(secret));
   1418         }
   1419         assert!(error.source().is_none());
   1420     }
   1421 
   1422     #[test]
   1423     fn boundary_failures_remain_typed_and_secret_safe() {
   1424         assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64);
   1425         for (error, code, category, retryable, recovery, message) in [
   1426             (
   1427                 runtime_unavailable(),
   1428                 WireErrorCode::InvalidApplicationState,
   1429                 WireErrorCategory::Lifecycle,
   1430                 true,
   1431                 WireRecoveryAction::RestartApplication,
   1432                 "The application runtime is unavailable.",
   1433             ),
   1434             (
   1435                 path_unavailable(),
   1436                 WireErrorCode::StorageUnavailable,
   1437                 WireErrorCategory::Storage,
   1438                 true,
   1439                 WireRecoveryAction::RestartApplication,
   1440                 "The application data directory is unavailable.",
   1441             ),
   1442             (
   1443                 confirmation_expired(),
   1444                 WireErrorCode::InvalidApplicationState,
   1445                 WireErrorCategory::Lifecycle,
   1446                 false,
   1447                 WireRecoveryAction::None,
   1448                 "The identity removal confirmation is no longer valid.",
   1449             ),
   1450             (
   1451                 generated_commit_failed(SafeError::new(
   1452                     harvestcircle_domain::SafeErrorCode::StorageUnavailable,
   1453                     harvestcircle_domain::SafeMessage::new("internal detail"),
   1454                 )),
   1455                 WireErrorCode::StorageUnavailable,
   1456                 WireErrorCategory::Storage,
   1457                 false,
   1458                 WireRecoveryAction::None,
   1459                 "The generated identity could not be saved. Import the recovery key you saved to try again.",
   1460             ),
   1461         ] {
   1462             assert_eq!(error.to_string(), message);
   1463             assert!(matches!(
   1464                 error,
   1465                 HarvestCircleError::Failure {
   1466                     code: actual_code,
   1467                     category: actual_category,
   1468                     retryable: actual_retryable,
   1469                     recovery_action: actual_recovery,
   1470                     correlation_id: None,
   1471                     safe_message,
   1472                 } if actual_code == code
   1473                     && actual_category == category
   1474                     && actual_retryable == retryable
   1475                     && actual_recovery == recovery
   1476                     && safe_message == message
   1477             ));
   1478         }
   1479     }
   1480 
   1481     #[test]
   1482     fn compatibility_matrix_rejects_before_storage_mutation() {
   1483         let actual = compatibility_descriptor();
   1484         let compatible = CompatibilityExpectation {
   1485             contract_id: FFI_CONTRACT_ID.to_owned(),
   1486             contract_major: FFI_CONTRACT_MAJOR,
   1487             minimum_contract_minor: FFI_CONTRACT_MINOR,
   1488             contract_hash: FFI_CONTRACT_HASH.to_owned(),
   1489             product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(),
   1490             snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
   1491             minimum_schema_version: 1,
   1492             maximum_schema_version: CURRENT_SCHEMA_VERSION,
   1493         };
   1494         verify_compatibility(&compatible).expect("compatible");
   1495 
   1496         for incompatible in [
   1497             CompatibilityExpectation {
   1498                 contract_id: "wrong-contract".to_owned(),
   1499                 ..compatible.clone()
   1500             },
   1501             CompatibilityExpectation {
   1502                 contract_major: FFI_CONTRACT_MAJOR + 1,
   1503                 ..compatible.clone()
   1504             },
   1505             CompatibilityExpectation {
   1506                 minimum_contract_minor: FFI_CONTRACT_MINOR + 1,
   1507                 ..compatible.clone()
   1508             },
   1509             CompatibilityExpectation {
   1510                 contract_hash: "wrong-contract".to_owned(),
   1511                 ..compatible.clone()
   1512             },
   1513             CompatibilityExpectation {
   1514                 product_coordinate_digest: "wrong-coordinates".to_owned(),
   1515                 ..compatible.clone()
   1516             },
   1517             CompatibilityExpectation {
   1518                 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION + 1,
   1519                 ..compatible.clone()
   1520             },
   1521             CompatibilityExpectation {
   1522                 minimum_schema_version: actual.current_schema_version + 1,
   1523                 ..compatible.clone()
   1524             },
   1525             CompatibilityExpectation {
   1526                 maximum_schema_version: actual.minimum_schema_version - 1,
   1527                 ..compatible.clone()
   1528             },
   1529         ] {
   1530             assert!(verify_compatibility(&incompatible).is_err());
   1531         }
   1532 
   1533         let oversized_relay_error = HarvestCircleAppCore::open_compatible(
   1534             compatible.clone(),
   1535             RuntimeOpenInputDto {
   1536                 development_mode: true,
   1537                 explicit_data_directory: Some("relative/path-must-not-be-read".to_owned()),
   1538                 relay_input: RelayBootstrapInputDto {
   1539                     endpoints: (0..=MAX_CONFIGURED_RELAYS)
   1540                         .map(|index| RelayEndpointDto {
   1541                             url: format!("wss://relay-{index}.example"),
   1542                             destination: RelayDestinationDto::Public,
   1543                             read: true,
   1544                             write: true,
   1545                         })
   1546                         .collect(),
   1547                 },
   1548             },
   1549         )
   1550         .err()
   1551         .expect("relay bound must reject before path inspection");
   1552         assert!(matches!(
   1553             oversized_relay_error,
   1554             HarvestCircleError::Failure {
   1555                 code: WireErrorCode::InvalidRelayConfiguration,
   1556                 ..
   1557             }
   1558         ));
   1559 
   1560         let directory = tempfile::tempdir().expect("directory");
   1561         let canonical = directory
   1562             .path()
   1563             .canonicalize()
   1564             .expect("canonical directory");
   1565         let input = RuntimeOpenInputDto {
   1566             development_mode: true,
   1567             explicit_data_directory: Some(canonical.to_string_lossy().into_owned()),
   1568             relay_input: RelayBootstrapInputDto {
   1569                 endpoints: Vec::new(),
   1570             },
   1571         };
   1572         let context = application_runtime_context(&input).expect("context");
   1573         let rejected = HarvestCircleStorageContract::from_runtime_context(&context)
   1574             .expect("storage contract")
   1575             .paths()
   1576             .state_database()
   1577             .to_path_buf();
   1578         let incompatible = CompatibilityExpectation {
   1579             contract_major: FFI_CONTRACT_MAJOR + 1,
   1580             ..compatible
   1581         };
   1582         assert!(
   1583             HarvestCircleAppCore::open_context_compatible(
   1584                 &context,
   1585                 &incompatible,
   1586                 input.relay_input,
   1587             )
   1588             .is_err()
   1589         );
   1590         assert!(!rejected.exists());
   1591     }
   1592 
   1593     #[test]
   1594     fn final_product_coordinates_do_not_adopt_the_temporary_namespace() {
   1595         assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr");
   1596         let temporary = tempfile::tempdir().expect("directory");
   1597         let canonical = temporary
   1598             .path()
   1599             .canonicalize()
   1600             .expect("canonical directory");
   1601         let context = application_runtime_context(&RuntimeOpenInputDto {
   1602             development_mode: true,
   1603             explicit_data_directory: Some(canonical.to_string_lossy().into_owned()),
   1604             relay_input: RelayBootstrapInputDto {
   1605                 endpoints: Vec::new(),
   1606             },
   1607         })
   1608         .expect("context");
   1609         assert_eq!(context.service().as_str(), "harvestcircle");
   1610         assert_eq!(context.instance().as_str(), "desktop");
   1611         let database = HarvestCircleStorageContract::from_runtime_context(&context)
   1612             .expect("storage contract")
   1613             .paths()
   1614             .state_database()
   1615             .to_path_buf();
   1616         assert!(database.ends_with("data/services/harvestcircle/desktop/state.sqlite"));
   1617         assert!(!database.to_string_lossy().contains("harvestcircle.sqlite3"));
   1618         assert_eq!(CURRENT_SCHEMA_VERSION, 3);
   1619     }
   1620 
   1621     #[test]
   1622     fn explicit_development_data_directory_is_exact_and_fail_closed() {
   1623         let temporary = tempfile::tempdir().expect("directory");
   1624         let canonical = temporary
   1625             .path()
   1626             .canonicalize()
   1627             .expect("canonical directory");
   1628         let relay_input = RelayBootstrapInputDto {
   1629             endpoints: Vec::new(),
   1630         };
   1631         let explicit = RuntimeOpenInputDto {
   1632             development_mode: true,
   1633             explicit_data_directory: Some(canonical.to_string_lossy().into_owned()),
   1634             relay_input: relay_input.clone(),
   1635         };
   1636         let context = application_runtime_context(&explicit).expect("explicit context");
   1637         assert_eq!(context.repo_local_root(), Some(canonical.as_path()));
   1638         assert!(
   1639             HarvestCircleStorageContract::from_runtime_context(&context)
   1640                 .expect("storage contract")
   1641                 .paths()
   1642                 .state_database()
   1643                 .ends_with("data/services/harvestcircle/desktop/state.sqlite")
   1644         );
   1645 
   1646         for rejected in [
   1647             RuntimeOpenInputDto {
   1648                 development_mode: false,
   1649                 explicit_data_directory: explicit.explicit_data_directory.clone(),
   1650                 relay_input: relay_input.clone(),
   1651             },
   1652             RuntimeOpenInputDto {
   1653                 development_mode: true,
   1654                 explicit_data_directory: Some("relative/data".to_owned()),
   1655                 relay_input: relay_input.clone(),
   1656             },
   1657             RuntimeOpenInputDto {
   1658                 development_mode: true,
   1659                 explicit_data_directory: Some(
   1660                     canonical.join("missing").to_string_lossy().into_owned(),
   1661                 ),
   1662                 relay_input,
   1663             },
   1664         ] {
   1665             assert!(application_runtime_context(&rejected).is_err());
   1666         }
   1667     }
   1668 
   1669     #[cfg(unix)]
   1670     #[test]
   1671     fn explicit_development_data_directory_rejects_symbolic_links() {
   1672         use std::os::unix::fs::symlink;
   1673 
   1674         let temporary = tempfile::tempdir().expect("directory");
   1675         let target = temporary.path().join("target");
   1676         std::fs::create_dir(&target).expect("target");
   1677         let link = temporary.path().join("link");
   1678         symlink(&target, &link).expect("link");
   1679         let input = RuntimeOpenInputDto {
   1680             development_mode: true,
   1681             explicit_data_directory: Some(link.to_string_lossy().into_owned()),
   1682             relay_input: RelayBootstrapInputDto {
   1683                 endpoints: Vec::new(),
   1684             },
   1685         };
   1686 
   1687         assert!(application_runtime_context(&input).is_err());
   1688     }
   1689 
   1690     #[test]
   1691     fn superseded_v1_ffi_commands_are_absent() {
   1692         let commands = include_str!("commands.rs");
   1693         let observer = include_str!("observer.rs");
   1694         for forbidden in [
   1695             format!("pub async fn {}_identity(", "generate"),
   1696             format!("pub async fn {}_secret_key(", "import"),
   1697             format!("pub fn {}(development_mode", "open"),
   1698             format!("pub async fn {}(", "subscribe"),
   1699             format!("pub fn {}(&self)", "shutdown"),
   1700         ] {
   1701             assert!(!commands.contains(&forbidden));
   1702             assert!(!observer.contains(&forbidden));
   1703         }
   1704     }
   1705 
   1706     #[test]
   1707     fn invalid_relay_configuration_fails_before_runtime_mutation() {
   1708         assert!(relay_configuration_from_endpoints(&[]).is_err());
   1709     }
   1710 
   1711     #[test]
   1712     fn injected_relay_input_is_explicit_profile_bound_and_fail_closed() {
   1713         let local = relay_configuration_from_endpoints(&[
   1714             RelayEndpointInput::new(
   1715                 "ws://localhost:8080".to_owned(),
   1716                 RelayUrlPolicy::Local,
   1717                 true,
   1718                 true,
   1719             ),
   1720             RelayEndpointInput::new(
   1721                 "ws://127.0.0.1:8081".to_owned(),
   1722                 RelayUrlPolicy::Local,
   1723                 true,
   1724                 true,
   1725             ),
   1726         ])
   1727         .expect("explicit local profile");
   1728         assert_eq!(local.relays()[0].url().as_str(), "ws://localhost:8080");
   1729         assert_eq!(local.relays()[1].url().as_str(), "ws://127.0.0.1:8081");
   1730 
   1731         for input in [
   1732             Vec::new(),
   1733             vec![RelayEndpointInput::new(
   1734                 "https://not-a-relay.example".to_owned(),
   1735                 RelayUrlPolicy::Public,
   1736                 true,
   1737                 true,
   1738             )],
   1739             vec![
   1740                 RelayEndpointInput::new(
   1741                     "ws://localhost:8080".to_owned(),
   1742                     RelayUrlPolicy::Local,
   1743                     true,
   1744                     true,
   1745                 ),
   1746                 RelayEndpointInput::new(
   1747                     "wss://relay.example".to_owned(),
   1748                     RelayUrlPolicy::Public,
   1749                     true,
   1750                     true,
   1751                 ),
   1752             ],
   1753         ] {
   1754             assert_eq!(
   1755                 relay_configuration_from_endpoints(&input)
   1756                     .expect_err("invalid profile")
   1757                     .code(),
   1758                 harvestcircle_domain::SafeErrorCode::InvalidRelayConfiguration
   1759             );
   1760         }
   1761     }
   1762 }