myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

persistence.rs (75180B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 use std::fs;
      3 use std::path::{Component, Path, PathBuf};
      4 use std::time::{Duration, SystemTime, UNIX_EPOCH};
      5 
      6 use nostr::PublicKey;
      7 use radroots_nostr_signer::prelude::{
      8     RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState,
      9     RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind,
     10     RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
     11     RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore,
     12 };
     13 use serde::{Deserialize, Serialize};
     14 
     15 use crate::app::MycRuntimePaths;
     16 use crate::audit::MycJsonlOperationAuditStore;
     17 use crate::audit_sqlite::MycSqliteOperationAuditStore;
     18 use crate::config::{
     19     MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycRuntimeAuditBackend,
     20     MycSignerStateBackend,
     21 };
     22 use crate::custody::MycIdentityProvider;
     23 use crate::error::MycError;
     24 use crate::identity_files::encrypted_identity_wrapping_key_path;
     25 use crate::outbox::{
     26     MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore,
     27 };
     28 use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore;
     29 
     30 const MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION: u32 = 1;
     31 const MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME: &str = "manifest.json";
     32 const MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME: &str = "state";
     33 const MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME: &str = "identity-references";
     34 
     35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     36 pub struct MycPersistenceImportSelection {
     37     import_signer_state: bool,
     38     import_runtime_audit: bool,
     39 }
     40 
     41 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     42 pub struct MycPersistenceImportJsonToSqliteOutput {
     43     #[serde(default, skip_serializing_if = "Option::is_none")]
     44     pub signer_state: Option<MycSignerStateImportOutput>,
     45     #[serde(default, skip_serializing_if = "Option::is_none")]
     46     pub runtime_audit: Option<MycRuntimeAuditImportOutput>,
     47 }
     48 
     49 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     50 pub struct MycSignerStateImportOutput {
     51     pub source_path: PathBuf,
     52     pub destination_path: PathBuf,
     53     #[serde(default, skip_serializing_if = "Option::is_none")]
     54     pub signer_identity_id: Option<String>,
     55     pub connection_count: usize,
     56     pub request_audit_count: usize,
     57 }
     58 
     59 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     60 pub struct MycRuntimeAuditImportOutput {
     61     pub source_dir: PathBuf,
     62     pub destination_path: PathBuf,
     63     pub record_count: usize,
     64 }
     65 
     66 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     67 pub struct MycPersistenceVerifyRestoreOutput {
     68     pub signer_identity_id: String,
     69     pub user_identity_id: String,
     70     #[serde(default, skip_serializing_if = "Option::is_none")]
     71     pub discovery_app_identity_id: Option<String>,
     72     pub signer_state: MycSignerStateVerifyRestoreOutput,
     73     pub runtime_audit: MycRuntimeAuditVerifyRestoreOutput,
     74     pub delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput,
     75 }
     76 
     77 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     78 pub struct MycPersistenceBackupOutput {
     79     pub backup_dir: PathBuf,
     80     pub manifest_path: PathBuf,
     81     pub state_dir: MycPersistenceBackupStateOutput,
     82     pub signer_identity_reference: MycPersistenceIdentityReferenceBackupOutput,
     83     pub user_identity_reference: MycPersistenceIdentityReferenceBackupOutput,
     84     #[serde(default, skip_serializing_if = "Option::is_none")]
     85     pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceBackupOutput>,
     86 }
     87 
     88 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     89 pub struct MycPersistenceBackupStateOutput {
     90     pub source_path: PathBuf,
     91     pub destination_path: PathBuf,
     92     pub file_count: usize,
     93 }
     94 
     95 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     96 pub struct MycPersistenceRestoreOutput {
     97     pub backup_dir: PathBuf,
     98     pub manifest_path: PathBuf,
     99     pub state_dir: MycPersistenceRestoreStateOutput,
    100     pub signer_identity_reference: MycPersistenceIdentityReferenceRestoreOutput,
    101     pub user_identity_reference: MycPersistenceIdentityReferenceRestoreOutput,
    102     #[serde(default, skip_serializing_if = "Option::is_none")]
    103     pub discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceRestoreOutput>,
    104 }
    105 
    106 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    107 pub struct MycPersistenceRestoreStateOutput {
    108     pub source_path: PathBuf,
    109     pub destination_path: PathBuf,
    110     pub file_count: usize,
    111 }
    112 
    113 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    114 pub struct MycPersistenceIdentityReferenceBackupOutput {
    115     pub role: String,
    116     pub backend: MycIdentityBackend,
    117     pub copied_file_count: usize,
    118     pub copied_files: Vec<PathBuf>,
    119     pub contains_secret_material: bool,
    120     pub requires_out_of_backup_dependencies: bool,
    121 }
    122 
    123 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    124 pub struct MycPersistenceIdentityReferenceRestoreOutput {
    125     pub role: String,
    126     pub backend: MycIdentityBackend,
    127     pub restored_file_count: usize,
    128     pub restored_files: Vec<PathBuf>,
    129     pub contains_secret_material: bool,
    130     pub requires_out_of_backup_dependencies: bool,
    131 }
    132 
    133 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    134 pub struct MycSignerStateVerifyRestoreOutput {
    135     pub backend: MycSignerStateBackend,
    136     pub path: PathBuf,
    137     pub connection_count: usize,
    138     pub request_audit_count: usize,
    139     pub publish_workflow_count: usize,
    140 }
    141 
    142 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    143 pub struct MycRuntimeAuditVerifyRestoreOutput {
    144     pub backend: MycRuntimeAuditBackend,
    145     pub path: PathBuf,
    146     pub record_count: usize,
    147 }
    148 
    149 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    150 pub struct MycDeliveryOutboxVerifyRestoreOutput {
    151     pub path: PathBuf,
    152     pub total_job_count: usize,
    153     pub queued_job_count: usize,
    154     pub published_pending_finalize_job_count: usize,
    155     pub finalized_job_count: usize,
    156     pub failed_job_count: usize,
    157     pub unfinished_job_count: usize,
    158 }
    159 
    160 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    161 struct MycPersistenceBackupManifest {
    162     version: u32,
    163     created_at_unix: u64,
    164     signer_state_backend: MycSignerStateBackend,
    165     runtime_audit_backend: MycRuntimeAuditBackend,
    166     state_dir: MycPersistenceBackupStateManifest,
    167     signer_identity_reference: MycPersistenceIdentityReferenceManifest,
    168     user_identity_reference: MycPersistenceIdentityReferenceManifest,
    169     #[serde(default, skip_serializing_if = "Option::is_none")]
    170     discovery_app_identity_reference: Option<MycPersistenceIdentityReferenceManifest>,
    171 }
    172 
    173 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    174 struct MycPersistenceBackupStateManifest {
    175     relative_path: PathBuf,
    176     files: Vec<PathBuf>,
    177 }
    178 
    179 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    180 struct MycPersistenceIdentityReferenceManifest {
    181     role: String,
    182     source: MycIdentitySourceSpec,
    183     files: Vec<MycPersistenceIdentityReferenceFileManifest>,
    184 }
    185 
    186 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    187 struct MycPersistenceIdentityReferenceFileManifest {
    188     field: MycPersistenceIdentityReferenceField,
    189     relative_path: PathBuf,
    190 }
    191 
    192 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    193 #[serde(rename_all = "snake_case")]
    194 enum MycPersistenceIdentityReferenceField {
    195     Path,
    196     EncryptedKeyPath,
    197     ProfilePath,
    198 }
    199 
    200 impl MycPersistenceImportSelection {
    201     pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self {
    202         Self {
    203             import_signer_state,
    204             import_runtime_audit,
    205         }
    206     }
    207 
    208     fn resolve(self, config: &MycConfig) -> Result<Self, MycError> {
    209         let import_signer_state = if self.import_signer_state || self.import_runtime_audit {
    210             self.import_signer_state
    211         } else {
    212             config.persistence.signer_state_backend == MycSignerStateBackend::Sqlite
    213         };
    214         let import_runtime_audit = if self.import_signer_state || self.import_runtime_audit {
    215             self.import_runtime_audit
    216         } else {
    217             config.persistence.runtime_audit_backend == MycRuntimeAuditBackend::Sqlite
    218         };
    219 
    220         if import_signer_state
    221             && config.persistence.signer_state_backend != MycSignerStateBackend::Sqlite
    222         {
    223             return Err(MycError::InvalidOperation(
    224                 "json-to-sqlite signer-state import requires MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite"
    225                     .to_owned(),
    226             ));
    227         }
    228         if import_runtime_audit
    229             && config.persistence.runtime_audit_backend != MycRuntimeAuditBackend::Sqlite
    230         {
    231             return Err(MycError::InvalidOperation(
    232                 "json-to-sqlite runtime-audit import requires MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite"
    233                     .to_owned(),
    234             ));
    235         }
    236         if !import_signer_state && !import_runtime_audit {
    237             return Err(MycError::InvalidOperation(
    238                 "json-to-sqlite import requires at least one sqlite-backed destination".to_owned(),
    239             ));
    240         }
    241 
    242         Ok(Self {
    243             import_signer_state,
    244             import_runtime_audit,
    245         })
    246     }
    247 }
    248 
    249 pub fn import_json_to_sqlite(
    250     config: &MycConfig,
    251     selection: MycPersistenceImportSelection,
    252 ) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> {
    253     config.validate()?;
    254     let selection = selection.resolve(config)?;
    255     let state_dir = &config.paths.state_dir;
    256     let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
    257     fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir {
    258         path: state_dir.clone(),
    259         source,
    260     })?;
    261     fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir {
    262         path: audit_dir.clone(),
    263         source,
    264     })?;
    265     let mut output = MycPersistenceImportJsonToSqliteOutput {
    266         signer_state: None,
    267         runtime_audit: None,
    268     };
    269 
    270     if selection.import_signer_state {
    271         output.signer_state = Some(import_signer_state_json_to_sqlite(config)?);
    272     }
    273     if selection.import_runtime_audit {
    274         output.runtime_audit = Some(import_runtime_audit_jsonl_to_sqlite(config, &audit_dir)?);
    275     }
    276 
    277     Ok(output)
    278 }
    279 
    280 pub fn backup_persistence(
    281     config: &MycConfig,
    282     output_dir: impl AsRef<Path>,
    283 ) -> Result<MycPersistenceBackupOutput, MycError> {
    284     config.validate()?;
    285 
    286     let output_dir = output_dir.as_ref().to_path_buf();
    287     let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME);
    288     let state_dir = &config.paths.state_dir;
    289     let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
    290     let signer_state_path = MycRuntimePaths::signer_state_path_for_backend(
    291         state_dir,
    292         config.persistence.signer_state_backend,
    293     );
    294     let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend(
    295         &audit_dir,
    296         config.persistence.runtime_audit_backend,
    297     );
    298     let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir);
    299 
    300     ensure_directory_empty_or_create(&output_dir, "backup destination")?;
    301     require_existing_restore_file(
    302         &signer_state_path,
    303         format!(
    304             "{} signer-state backend",
    305             config.persistence.signer_state_backend.as_str()
    306         ),
    307     )?;
    308     require_existing_restore_file(
    309         &runtime_audit_path,
    310         format!(
    311             "{} runtime-audit backend",
    312             config.persistence.runtime_audit_backend.as_str()
    313         ),
    314     )?;
    315     require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?;
    316 
    317     let backup_state_dir = output_dir.join(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME);
    318     let state_files = copy_dir_recursive_collect(state_dir, &backup_state_dir)?;
    319     let signer_identity_reference = backup_identity_reference(
    320         "signer",
    321         &config.paths.signer_identity_source(),
    322         &output_dir,
    323     )?;
    324     let user_identity_reference =
    325         backup_identity_reference("user", &config.paths.user_identity_source(), &output_dir)?;
    326     let discovery_app_identity_reference = config
    327         .discovery
    328         .app_identity_source()
    329         .map(|source| backup_identity_reference("discovery-app", &source, &output_dir))
    330         .transpose()?;
    331 
    332     let manifest = MycPersistenceBackupManifest {
    333         version: MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION,
    334         created_at_unix: now_unix_secs(),
    335         signer_state_backend: config.persistence.signer_state_backend,
    336         runtime_audit_backend: config.persistence.runtime_audit_backend,
    337         state_dir: MycPersistenceBackupStateManifest {
    338             relative_path: PathBuf::from(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME),
    339             files: state_files.clone(),
    340         },
    341         signer_identity_reference: signer_identity_reference.manifest,
    342         user_identity_reference: user_identity_reference.manifest,
    343         discovery_app_identity_reference: discovery_app_identity_reference
    344             .as_ref()
    345             .map(|output| output.manifest.clone()),
    346     };
    347     write_json_file(&backup_manifest_path, &manifest)?;
    348 
    349     Ok(MycPersistenceBackupOutput {
    350         backup_dir: output_dir.clone(),
    351         manifest_path: backup_manifest_path,
    352         state_dir: MycPersistenceBackupStateOutput {
    353             source_path: state_dir.clone(),
    354             destination_path: backup_state_dir,
    355             file_count: state_files.len(),
    356         },
    357         signer_identity_reference: signer_identity_reference.output,
    358         user_identity_reference: user_identity_reference.output,
    359         discovery_app_identity_reference: discovery_app_identity_reference
    360             .map(|output| output.output),
    361     })
    362 }
    363 
    364 pub fn restore_backup(
    365     config: &MycConfig,
    366     backup_dir: impl AsRef<Path>,
    367 ) -> Result<MycPersistenceRestoreOutput, MycError> {
    368     config.validate()?;
    369 
    370     let backup_dir = backup_dir.as_ref().to_path_buf();
    371     let backup_manifest_path = backup_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME);
    372     let manifest = read_json_file::<MycPersistenceBackupManifest>(&backup_manifest_path)?;
    373     validate_backup_manifest(config, &manifest)?;
    374 
    375     let state_source_dir = backup_dir.join(&manifest.state_dir.relative_path);
    376     if !state_source_dir.is_dir() {
    377         return Err(MycError::InvalidOperation(format!(
    378             "persistence restore requires an existing backed-up state directory at {}",
    379             state_source_dir.display()
    380         )));
    381     }
    382 
    383     ensure_restore_state_destination_clear(&config.paths.state_dir)?;
    384     let signer_identity_reference = restore_identity_reference(
    385         &backup_dir,
    386         &manifest.signer_identity_reference,
    387         &config.paths.signer_identity_source(),
    388     )?;
    389     let user_identity_reference = restore_identity_reference(
    390         &backup_dir,
    391         &manifest.user_identity_reference,
    392         &config.paths.user_identity_source(),
    393     )?;
    394     let discovery_app_identity_reference = match (
    395         manifest.discovery_app_identity_reference.as_ref(),
    396         config.discovery.app_identity_source(),
    397     ) {
    398         (Some(manifest_reference), Some(current_source)) => Some(restore_identity_reference(
    399             &backup_dir,
    400             manifest_reference,
    401             &current_source,
    402         )?),
    403         _ => None,
    404     };
    405 
    406     let restored_state_files =
    407         copy_dir_recursive_collect(&state_source_dir, &config.paths.state_dir)?;
    408 
    409     Ok(MycPersistenceRestoreOutput {
    410         backup_dir: backup_dir.clone(),
    411         manifest_path: backup_manifest_path,
    412         state_dir: MycPersistenceRestoreStateOutput {
    413             source_path: state_source_dir,
    414             destination_path: config.paths.state_dir.clone(),
    415             file_count: restored_state_files.len(),
    416         },
    417         signer_identity_reference,
    418         user_identity_reference,
    419         discovery_app_identity_reference,
    420     })
    421 }
    422 
    423 pub fn verify_restored_state(
    424     config: &MycConfig,
    425 ) -> Result<MycPersistenceVerifyRestoreOutput, MycError> {
    426     config.validate()?;
    427 
    428     let state_dir = &config.paths.state_dir;
    429     let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
    430     let signer_state_path = MycRuntimePaths::signer_state_path_for_backend(
    431         state_dir,
    432         config.persistence.signer_state_backend,
    433     );
    434     let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend(
    435         &audit_dir,
    436         config.persistence.runtime_audit_backend,
    437     );
    438     let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir);
    439 
    440     require_existing_restore_file(
    441         &signer_state_path,
    442         format!(
    443             "{} signer-state backend",
    444             config.persistence.signer_state_backend.as_str()
    445         ),
    446     )?;
    447     require_existing_restore_file(
    448         &runtime_audit_path,
    449         format!(
    450             "{} runtime-audit backend",
    451             config.persistence.runtime_audit_backend.as_str()
    452         ),
    453     )?;
    454     require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?;
    455 
    456     let signer_identity_provider = MycIdentityProvider::from_source(
    457         "signer",
    458         config.paths.signer_identity_source(),
    459         Duration::from_secs(config.custody.external_command_timeout_secs),
    460     )?;
    461     let signer_identity = signer_identity_provider.load_active_identity()?;
    462     let user_identity_provider = MycIdentityProvider::from_source(
    463         "user",
    464         config.paths.user_identity_source(),
    465         Duration::from_secs(config.custody.external_command_timeout_secs),
    466     )?;
    467     let user_identity = user_identity_provider.load_active_identity()?;
    468     let discovery_app_identity = match config.discovery.app_identity_source() {
    469         Some(source) => Some(MycIdentityProvider::from_source(
    470             "discovery app",
    471             source,
    472             Duration::from_secs(config.custody.external_command_timeout_secs),
    473         )?),
    474         None => None,
    475     }
    476     .map(|provider| provider.load_active_identity())
    477     .transpose()?;
    478 
    479     let signer_state = load_existing_signer_state(config, &signer_state_path)?;
    480     let configured_signer_identity = signer_identity.to_public();
    481     if let Some(existing_signer_identity) = signer_state.signer_identity.as_ref() {
    482         if existing_signer_identity.id != configured_signer_identity.id {
    483             return Err(MycError::SignerIdentityMismatch {
    484                 identity_path: config.paths.signer_identity_path.clone(),
    485                 state_path: signer_state_path.clone(),
    486                 configured_identity_id: configured_signer_identity.id.to_string(),
    487                 persisted_identity_id: existing_signer_identity.id.to_string(),
    488             });
    489         }
    490     }
    491 
    492     let runtime_audit_record_count = load_existing_runtime_audit_record_count(config, &audit_dir)?;
    493     let outbox_store = MycSqliteDeliveryOutboxStore::open(state_dir)?;
    494     let outbox_records = outbox_store.list_all()?;
    495     verify_restored_delivery_state(
    496         &signer_state,
    497         &outbox_records,
    498         signer_identity.public_key(),
    499         discovery_app_identity
    500             .as_ref()
    501             .map(|identity| identity.public_key()),
    502     )?;
    503 
    504     let mut queued_job_count = 0usize;
    505     let mut published_pending_finalize_job_count = 0usize;
    506     let mut finalized_job_count = 0usize;
    507     let mut failed_job_count = 0usize;
    508     for record in &outbox_records {
    509         match record.status {
    510             MycDeliveryOutboxStatus::Queued => queued_job_count += 1,
    511             MycDeliveryOutboxStatus::PublishedPendingFinalize => {
    512                 published_pending_finalize_job_count += 1
    513             }
    514             MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1,
    515             MycDeliveryOutboxStatus::Failed => failed_job_count += 1,
    516         }
    517     }
    518 
    519     Ok(MycPersistenceVerifyRestoreOutput {
    520         signer_identity_id: signer_identity.id().to_string(),
    521         user_identity_id: user_identity.id().to_string(),
    522         discovery_app_identity_id: discovery_app_identity
    523             .as_ref()
    524             .map(|identity| identity.id().to_string()),
    525         signer_state: MycSignerStateVerifyRestoreOutput {
    526             backend: config.persistence.signer_state_backend,
    527             path: signer_state_path,
    528             connection_count: signer_state.connections.len(),
    529             request_audit_count: signer_state.audit_records.len(),
    530             publish_workflow_count: signer_state.publish_workflows.len(),
    531         },
    532         runtime_audit: MycRuntimeAuditVerifyRestoreOutput {
    533             backend: config.persistence.runtime_audit_backend,
    534             path: runtime_audit_path,
    535             record_count: runtime_audit_record_count,
    536         },
    537         delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput {
    538             path: delivery_outbox_path,
    539             total_job_count: outbox_records.len(),
    540             queued_job_count,
    541             published_pending_finalize_job_count,
    542             finalized_job_count,
    543             failed_job_count,
    544             unfinished_job_count: queued_job_count + published_pending_finalize_job_count,
    545         },
    546     })
    547 }
    548 
    549 fn import_signer_state_json_to_sqlite(
    550     config: &MycConfig,
    551 ) -> Result<MycSignerStateImportOutput, MycError> {
    552     let source_path = MycRuntimePaths::signer_state_path_for_backend(
    553         &config.paths.state_dir,
    554         MycSignerStateBackend::JsonFile,
    555     );
    556     let destination_path = MycRuntimePaths::signer_state_path_for_backend(
    557         &config.paths.state_dir,
    558         MycSignerStateBackend::Sqlite,
    559     );
    560     let source_store = RadrootsNostrFileSignerStore::new(&source_path);
    561     let source_state = source_store.load()?;
    562     let signer_identity_provider = MycIdentityProvider::from_source(
    563         "signer",
    564         config.paths.signer_identity_source(),
    565         Duration::from_secs(config.custody.external_command_timeout_secs),
    566     )?;
    567     let configured_signer_identity = signer_identity_provider.load_identity()?.to_public();
    568     if let Some(imported_signer_identity) = source_state.signer_identity.as_ref() {
    569         if imported_signer_identity.id != configured_signer_identity.id {
    570             return Err(MycError::SignerIdentityImportMismatch {
    571                 state_path: source_path.clone(),
    572                 configured_identity_id: configured_signer_identity.id.to_string(),
    573                 imported_identity_id: imported_signer_identity.id.to_string(),
    574             });
    575         }
    576     }
    577 
    578     let destination_store = RadrootsNostrSqliteSignerStore::open(&destination_path)?;
    579     let existing_destination_state = destination_store.load()?;
    580     if !signer_store_state_is_empty(&existing_destination_state) {
    581         return Err(MycError::InvalidOperation(format!(
    582             "sqlite signer-state destination {} is not empty; refusing import",
    583             destination_path.display()
    584         )));
    585     }
    586 
    587     destination_store.save(&source_state)?;
    588 
    589     Ok(MycSignerStateImportOutput {
    590         source_path,
    591         destination_path,
    592         signer_identity_id: source_state
    593             .signer_identity
    594             .as_ref()
    595             .map(|identity| identity.id.to_string()),
    596         connection_count: source_state.connections.len(),
    597         request_audit_count: source_state.audit_records.len(),
    598     })
    599 }
    600 
    601 fn import_runtime_audit_jsonl_to_sqlite(
    602     config: &MycConfig,
    603     audit_dir: &std::path::Path,
    604 ) -> Result<MycRuntimeAuditImportOutput, MycError> {
    605     let source_store = MycJsonlOperationAuditStore::new(audit_dir, config.audit.clone());
    606     let source_records = source_store.list_all()?;
    607     let destination_store = MycSqliteOperationAuditStore::open(audit_dir, config.audit.clone())?;
    608     let existing_destination_records = destination_store.list_all()?;
    609     if !existing_destination_records.is_empty() {
    610         return Err(MycError::InvalidOperation(format!(
    611             "sqlite runtime-audit destination {} is not empty; refusing import",
    612             destination_store.path().display()
    613         )));
    614     }
    615     for record in &source_records {
    616         destination_store.append(record)?;
    617     }
    618 
    619     Ok(MycRuntimeAuditImportOutput {
    620         source_dir: audit_dir.to_path_buf(),
    621         destination_path: destination_store.path().to_path_buf(),
    622         record_count: source_records.len(),
    623     })
    624 }
    625 
    626 #[derive(Debug, Clone)]
    627 struct MycBackedUpIdentityReference {
    628     manifest: MycPersistenceIdentityReferenceManifest,
    629     output: MycPersistenceIdentityReferenceBackupOutput,
    630 }
    631 
    632 fn backup_identity_reference(
    633     role: &str,
    634     source: &MycIdentitySourceSpec,
    635     backup_dir: &Path,
    636 ) -> Result<MycBackedUpIdentityReference, MycError> {
    637     let role_dir = backup_dir
    638         .join(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME)
    639         .join(role);
    640     let mut manifest_files = Vec::new();
    641     let mut copied_files = Vec::new();
    642 
    643     if should_copy_identity_source_path(source.backend)
    644         && let Some(path) = source.path.as_ref()
    645     {
    646         let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME)
    647             .join(role)
    648             .join("path");
    649         copy_file_required(path, &backup_dir.join(&relative_path))?;
    650         manifest_files.push(MycPersistenceIdentityReferenceFileManifest {
    651             field: MycPersistenceIdentityReferenceField::Path,
    652             relative_path: relative_path.clone(),
    653         });
    654         copied_files.push(backup_dir.join(relative_path));
    655     }
    656 
    657     if source.backend == MycIdentityBackend::EncryptedFile
    658         && let Some(path) = source.path.as_ref()
    659     {
    660         let key_path = encrypted_identity_wrapping_key_path(path);
    661         let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME)
    662             .join(role)
    663             .join("encrypted-key-path");
    664         copy_file_required(&key_path, &backup_dir.join(&relative_path))?;
    665         manifest_files.push(MycPersistenceIdentityReferenceFileManifest {
    666             field: MycPersistenceIdentityReferenceField::EncryptedKeyPath,
    667             relative_path: relative_path.clone(),
    668         });
    669         copied_files.push(backup_dir.join(relative_path));
    670     }
    671 
    672     if let Some(profile_path) = source.profile_path.as_ref() {
    673         let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME)
    674             .join(role)
    675             .join("profile-path");
    676         copy_file_required(profile_path, &backup_dir.join(&relative_path))?;
    677         manifest_files.push(MycPersistenceIdentityReferenceFileManifest {
    678             field: MycPersistenceIdentityReferenceField::ProfilePath,
    679             relative_path: relative_path.clone(),
    680         });
    681         copied_files.push(backup_dir.join(relative_path));
    682     }
    683 
    684     if !manifest_files.is_empty() {
    685         fs::create_dir_all(&role_dir).map_err(|source| MycError::CreateDir {
    686             path: role_dir.clone(),
    687             source,
    688         })?;
    689     }
    690 
    691     Ok(MycBackedUpIdentityReference {
    692         manifest: MycPersistenceIdentityReferenceManifest {
    693             role: role.to_owned(),
    694             source: source.clone(),
    695             files: manifest_files,
    696         },
    697         output: MycPersistenceIdentityReferenceBackupOutput {
    698             role: role.to_owned(),
    699             backend: source.backend,
    700             copied_file_count: copied_files.len(),
    701             copied_files,
    702             contains_secret_material: matches!(
    703                 source.backend,
    704                 MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile
    705             ),
    706             requires_out_of_backup_dependencies: matches!(
    707                 source.backend,
    708                 MycIdentityBackend::HostVault
    709                     | MycIdentityBackend::ManagedAccount
    710                     | MycIdentityBackend::ExternalCommand
    711             ),
    712         },
    713     })
    714 }
    715 
    716 fn restore_identity_reference(
    717     backup_dir: &Path,
    718     manifest: &MycPersistenceIdentityReferenceManifest,
    719     current_source: &MycIdentitySourceSpec,
    720 ) -> Result<MycPersistenceIdentityReferenceRestoreOutput, MycError> {
    721     let mut restored_files = Vec::new();
    722 
    723     for file in &manifest.files {
    724         let source_path = backup_dir.join(&file.relative_path);
    725         let destination_path = match file.field {
    726             MycPersistenceIdentityReferenceField::Path => current_source.path.clone(),
    727             MycPersistenceIdentityReferenceField::EncryptedKeyPath => current_source
    728                 .path
    729                 .as_ref()
    730                 .map(|path| encrypted_identity_wrapping_key_path(path)),
    731             MycPersistenceIdentityReferenceField::ProfilePath => {
    732                 current_source.profile_path.clone()
    733             }
    734         }
    735         .ok_or_else(|| {
    736             MycError::InvalidOperation(format!(
    737                 "persistence restore requires `{}` identity `{}` destination to be configured",
    738                 manifest.role,
    739                 match file.field {
    740                     MycPersistenceIdentityReferenceField::Path => "path",
    741                     MycPersistenceIdentityReferenceField::EncryptedKeyPath => {
    742                         "encrypted_key_path"
    743                     }
    744                     MycPersistenceIdentityReferenceField::ProfilePath => "profile_path",
    745                 }
    746             ))
    747         })?;
    748 
    749         ensure_restore_destination_file_clear(
    750             &destination_path,
    751             format!(
    752                 "{} identity {}",
    753                 manifest.role,
    754                 restore_field_label(file.field)
    755             ),
    756         )?;
    757         copy_file_required(&source_path, &destination_path)?;
    758         restored_files.push(destination_path.clone());
    759     }
    760 
    761     Ok(MycPersistenceIdentityReferenceRestoreOutput {
    762         role: manifest.role.clone(),
    763         backend: current_source.backend,
    764         restored_file_count: restored_files.len(),
    765         restored_files,
    766         contains_secret_material: matches!(
    767             current_source.backend,
    768             MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile
    769         ),
    770         requires_out_of_backup_dependencies: matches!(
    771             current_source.backend,
    772             MycIdentityBackend::HostVault
    773                 | MycIdentityBackend::ManagedAccount
    774                 | MycIdentityBackend::ExternalCommand
    775         ),
    776     })
    777 }
    778 
    779 fn validate_backup_manifest(
    780     config: &MycConfig,
    781     manifest: &MycPersistenceBackupManifest,
    782 ) -> Result<(), MycError> {
    783     if manifest.version != MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION {
    784         return Err(MycError::InvalidOperation(format!(
    785             "persistence restore does not support backup manifest version {}; expected {}",
    786             manifest.version, MYC_PERSISTENCE_BACKUP_MANIFEST_VERSION
    787         )));
    788     }
    789     if manifest.signer_state_backend != config.persistence.signer_state_backend {
    790         return Err(MycError::InvalidOperation(format!(
    791             "persistence restore requires signer-state backend `{}` but the backup was created with `{}`",
    792             config.persistence.signer_state_backend.as_str(),
    793             manifest.signer_state_backend.as_str()
    794         )));
    795     }
    796     if manifest.runtime_audit_backend != config.persistence.runtime_audit_backend {
    797         return Err(MycError::InvalidOperation(format!(
    798             "persistence restore requires runtime-audit backend `{}` but the backup was created with `{}`",
    799             config.persistence.runtime_audit_backend.as_str(),
    800             manifest.runtime_audit_backend.as_str()
    801         )));
    802     }
    803     validate_manifest_relative_path(&manifest.state_dir.relative_path, "state directory")?;
    804     if manifest.state_dir.relative_path != Path::new(MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME) {
    805         return Err(MycError::InvalidOperation(format!(
    806             "persistence restore requires the backup state directory to be stored at `{}` but found `{}`",
    807             MYC_PERSISTENCE_BACKUP_STATE_DIR_NAME,
    808             manifest.state_dir.relative_path.display()
    809         )));
    810     }
    811     for relative_path in &manifest.state_dir.files {
    812         validate_manifest_relative_path(relative_path, "state file")?;
    813     }
    814     validate_identity_reference_manifest(&manifest.signer_identity_reference)?;
    815     validate_identity_reference_manifest(&manifest.user_identity_reference)?;
    816     if let Some(reference) = manifest.discovery_app_identity_reference.as_ref() {
    817         validate_identity_reference_manifest(reference)?;
    818     }
    819 
    820     validate_identity_source_compatibility(
    821         "signer",
    822         &config.paths.signer_identity_source(),
    823         &manifest.signer_identity_reference.source,
    824     )?;
    825     validate_identity_source_compatibility(
    826         "user",
    827         &config.paths.user_identity_source(),
    828         &manifest.user_identity_reference.source,
    829     )?;
    830 
    831     match (
    832         config.discovery.app_identity_source(),
    833         manifest.discovery_app_identity_reference.as_ref(),
    834     ) {
    835         (Some(current_source), Some(manifest_source)) => validate_identity_source_compatibility(
    836             "discovery app",
    837             &current_source,
    838             &manifest_source.source,
    839         )?,
    840         (None, None) => {}
    841         (Some(_), None) => {
    842             return Err(MycError::InvalidOperation(
    843                 "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(),
    844             ))
    845         }
    846         (None, Some(_)) => {
    847             return Err(MycError::InvalidOperation(
    848                 "persistence restore requires the current config discovery app identity contract to match the backup manifest".to_owned(),
    849             ))
    850         }
    851     }
    852 
    853     Ok(())
    854 }
    855 
    856 fn validate_identity_source_compatibility(
    857     role: &str,
    858     current: &MycIdentitySourceSpec,
    859     backed_up: &MycIdentitySourceSpec,
    860 ) -> Result<(), MycError> {
    861     if current.backend != backed_up.backend {
    862         return Err(MycError::InvalidOperation(format!(
    863             "persistence restore requires {role} identity backend `{}` but the backup was created with `{}`",
    864             current.backend.as_str(),
    865             backed_up.backend.as_str()
    866         )));
    867     }
    868     if current.keyring_account_id != backed_up.keyring_account_id {
    869         return Err(MycError::InvalidOperation(format!(
    870             "persistence restore requires the configured {role} keyring_account_id to match the backup manifest"
    871         )));
    872     }
    873     if current.keyring_service_name != backed_up.keyring_service_name {
    874         return Err(MycError::InvalidOperation(format!(
    875             "persistence restore requires the configured {role} keyring_service_name to match the backup manifest"
    876         )));
    877     }
    878     if current.profile_path.is_some() != backed_up.profile_path.is_some() {
    879         return Err(MycError::InvalidOperation(format!(
    880             "persistence restore requires the configured {role} profile_path contract to match the backup manifest"
    881         )));
    882     }
    883     if requires_identity_source_path_contract(current.backend)
    884         && current.path.is_some() != backed_up.path.is_some()
    885     {
    886         return Err(MycError::InvalidOperation(format!(
    887             "persistence restore requires the configured {role} path-based identity contract to match the backup manifest"
    888         )));
    889     }
    890     Ok(())
    891 }
    892 
    893 fn validate_identity_reference_manifest(
    894     manifest: &MycPersistenceIdentityReferenceManifest,
    895 ) -> Result<(), MycError> {
    896     for file in &manifest.files {
    897         validate_manifest_relative_path(
    898             &file.relative_path,
    899             &format!("{} identity reference file", manifest.role),
    900         )?;
    901     }
    902     Ok(())
    903 }
    904 
    905 fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<(), MycError> {
    906     if path.is_absolute()
    907         || path.components().any(|component| {
    908             matches!(
    909                 component,
    910                 Component::ParentDir | Component::RootDir | Component::Prefix(_)
    911             )
    912         })
    913     {
    914         return Err(MycError::InvalidOperation(format!(
    915             "persistence restore requires a relative `{label}` path inside the backup, but found `{}`",
    916             path.display()
    917         )));
    918     }
    919     Ok(())
    920 }
    921 
    922 fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool {
    923     matches!(
    924         backend,
    925         MycIdentityBackend::EncryptedFile
    926             | MycIdentityBackend::PlaintextFile
    927             | MycIdentityBackend::ManagedAccount
    928             | MycIdentityBackend::ExternalCommand
    929     )
    930 }
    931 
    932 fn should_copy_identity_source_path(backend: MycIdentityBackend) -> bool {
    933     matches!(
    934         backend,
    935         MycIdentityBackend::EncryptedFile
    936             | MycIdentityBackend::PlaintextFile
    937             | MycIdentityBackend::ManagedAccount
    938     )
    939 }
    940 
    941 fn ensure_directory_empty_or_create(path: &Path, label: &str) -> Result<(), MycError> {
    942     if path.exists() {
    943         if !path.is_dir() {
    944             return Err(MycError::InvalidOperation(format!(
    945                 "{label} {} already exists and is not a directory",
    946                 path.display()
    947             )));
    948         }
    949         let mut entries = fs::read_dir(path).map_err(|source| MycError::PersistenceIo {
    950             path: path.to_path_buf(),
    951             source,
    952         })?;
    953         if entries
    954             .next()
    955             .transpose()
    956             .map_err(|source| MycError::PersistenceIo {
    957                 path: path.to_path_buf(),
    958                 source,
    959             })?
    960             .is_some()
    961         {
    962             return Err(MycError::InvalidOperation(format!(
    963                 "{label} {} is not empty; refusing to overwrite it",
    964                 path.display()
    965             )));
    966         }
    967         return Ok(());
    968     }
    969 
    970     fs::create_dir_all(path).map_err(|source| MycError::CreateDir {
    971         path: path.to_path_buf(),
    972         source,
    973     })
    974 }
    975 
    976 fn ensure_restore_state_destination_clear(path: &Path) -> Result<(), MycError> {
    977     ensure_directory_empty_or_create(path, "restore state directory")
    978 }
    979 
    980 fn ensure_restore_destination_file_clear(path: &Path, label: String) -> Result<(), MycError> {
    981     if path.exists() {
    982         return Err(MycError::InvalidOperation(format!(
    983             "persistence restore requires an empty destination; {label} already exists at {}",
    984             path.display()
    985         )));
    986     }
    987     Ok(())
    988 }
    989 
    990 fn copy_dir_recursive_collect(source: &Path, destination: &Path) -> Result<Vec<PathBuf>, MycError> {
    991     if !source.is_dir() {
    992         return Err(MycError::InvalidOperation(format!(
    993             "persistence backup/restore requires a directory at {}",
    994             source.display()
    995         )));
    996     }
    997     ensure_copy_destination_is_not_nested(source, destination)?;
    998 
    999     fs::create_dir_all(destination).map_err(|source_error| MycError::CreateDir {
   1000         path: destination.to_path_buf(),
   1001         source: source_error,
   1002     })?;
   1003 
   1004     let mut copied_files = Vec::new();
   1005     copy_dir_recursive_collect_inner(source, destination, Path::new(""), &mut copied_files)?;
   1006     Ok(copied_files)
   1007 }
   1008 
   1009 fn copy_dir_recursive_collect_inner(
   1010     source_root: &Path,
   1011     destination_root: &Path,
   1012     relative_dir: &Path,
   1013     copied_files: &mut Vec<PathBuf>,
   1014 ) -> Result<(), MycError> {
   1015     let current_source_dir = source_root.join(relative_dir);
   1016     let entries = fs::read_dir(&current_source_dir).map_err(|source| MycError::PersistenceIo {
   1017         path: current_source_dir.clone(),
   1018         source,
   1019     })?;
   1020 
   1021     for entry in entries {
   1022         let entry = entry.map_err(|source| MycError::PersistenceIo {
   1023             path: current_source_dir.clone(),
   1024             source,
   1025         })?;
   1026         let entry_path = entry.path();
   1027         let relative_path = relative_dir.join(entry.file_name());
   1028         let destination_path = destination_root.join(&relative_path);
   1029         if entry_path.is_dir() {
   1030             fs::create_dir_all(&destination_path).map_err(|source| MycError::CreateDir {
   1031                 path: destination_path.clone(),
   1032                 source,
   1033             })?;
   1034             copy_dir_recursive_collect_inner(
   1035                 source_root,
   1036                 destination_root,
   1037                 &relative_path,
   1038                 copied_files,
   1039             )?;
   1040         } else {
   1041             copy_file_required(&entry_path, &destination_path)?;
   1042             copied_files.push(relative_path);
   1043         }
   1044     }
   1045 
   1046     Ok(())
   1047 }
   1048 
   1049 fn ensure_copy_destination_is_not_nested(
   1050     source: &Path,
   1051     destination: &Path,
   1052 ) -> Result<(), MycError> {
   1053     let source_absolute = absolute_path_for_copy_check(source)?;
   1054     let destination_absolute = absolute_path_for_copy_check(destination)?;
   1055     if destination_absolute == source_absolute || destination_absolute.starts_with(&source_absolute)
   1056     {
   1057         return Err(MycError::InvalidOperation(format!(
   1058             "persistence backup/restore cannot copy `{}` into nested destination `{}`",
   1059             source.display(),
   1060             destination.display()
   1061         )));
   1062     }
   1063     Ok(())
   1064 }
   1065 
   1066 fn absolute_path_for_copy_check(path: &Path) -> Result<PathBuf, MycError> {
   1067     if path.is_absolute() {
   1068         Ok(path.to_path_buf())
   1069     } else {
   1070         std::env::current_dir()
   1071             .map(|cwd| cwd.join(path))
   1072             .map_err(|source| MycError::PersistenceIo {
   1073                 path: path.to_path_buf(),
   1074                 source,
   1075             })
   1076     }
   1077 }
   1078 
   1079 fn copy_file_required(source: &Path, destination: &Path) -> Result<(), MycError> {
   1080     if !source.is_file() {
   1081         return Err(MycError::InvalidOperation(format!(
   1082             "persistence backup/restore requires an existing file at {}",
   1083             source.display()
   1084         )));
   1085     }
   1086     if let Some(parent) = destination.parent() {
   1087         fs::create_dir_all(parent).map_err(|source_error| MycError::CreateDir {
   1088             path: parent.to_path_buf(),
   1089             source: source_error,
   1090         })?;
   1091     }
   1092     fs::copy(source, destination).map_err(|source_error| MycError::PersistenceIo {
   1093         path: source.to_path_buf(),
   1094         source: source_error,
   1095     })?;
   1096     Ok(())
   1097 }
   1098 
   1099 fn write_json_file(path: &Path, value: &impl Serialize) -> Result<(), MycError> {
   1100     let rendered =
   1101         serde_json::to_string_pretty(value).map_err(|source| MycError::PersistenceSerialize {
   1102             path: path.to_path_buf(),
   1103             source,
   1104         })?;
   1105     if let Some(parent) = path.parent() {
   1106         fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
   1107             path: parent.to_path_buf(),
   1108             source,
   1109         })?;
   1110     }
   1111     fs::write(path, rendered).map_err(|source| MycError::PersistenceIo {
   1112         path: path.to_path_buf(),
   1113         source,
   1114     })?;
   1115     Ok(())
   1116 }
   1117 
   1118 fn read_json_file<T>(path: &Path) -> Result<T, MycError>
   1119 where
   1120     T: for<'de> Deserialize<'de>,
   1121 {
   1122     let contents = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo {
   1123         path: path.to_path_buf(),
   1124         source,
   1125     })?;
   1126     serde_json::from_str(&contents).map_err(|source| MycError::PersistenceManifestParse {
   1127         path: path.to_path_buf(),
   1128         source,
   1129     })
   1130 }
   1131 
   1132 fn restore_field_label(field: MycPersistenceIdentityReferenceField) -> &'static str {
   1133     match field {
   1134         MycPersistenceIdentityReferenceField::Path => "path",
   1135         MycPersistenceIdentityReferenceField::EncryptedKeyPath => "encrypted_key_path",
   1136         MycPersistenceIdentityReferenceField::ProfilePath => "profile_path",
   1137     }
   1138 }
   1139 
   1140 fn now_unix_secs() -> u64 {
   1141     SystemTime::now()
   1142         .duration_since(UNIX_EPOCH)
   1143         .map(|duration| duration.as_secs())
   1144         .unwrap_or(0)
   1145 }
   1146 
   1147 fn signer_store_state_is_empty(
   1148     state: &radroots_nostr_signer::prelude::RadrootsNostrSignerStoreState,
   1149 ) -> bool {
   1150     state.signer_identity.is_none()
   1151         && state.connections.is_empty()
   1152         && state.audit_records.is_empty()
   1153         && state.publish_workflows.is_empty()
   1154 }
   1155 
   1156 fn require_existing_restore_file(path: &std::path::Path, label: String) -> Result<(), MycError> {
   1157     if path.is_file() {
   1158         return Ok(());
   1159     }
   1160     Err(MycError::InvalidOperation(format!(
   1161         "persistence verify-restore requires an existing {label} file at {}",
   1162         path.display()
   1163     )))
   1164 }
   1165 
   1166 fn load_existing_signer_state(
   1167     config: &MycConfig,
   1168     signer_state_path: &std::path::Path,
   1169 ) -> Result<RadrootsNostrSignerStoreState, MycError> {
   1170     match config.persistence.signer_state_backend {
   1171         MycSignerStateBackend::JsonFile => RadrootsNostrFileSignerStore::new(signer_state_path)
   1172             .load()
   1173             .map_err(MycError::from),
   1174         MycSignerStateBackend::Sqlite => RadrootsNostrSqliteSignerStore::open(signer_state_path)?
   1175             .load()
   1176             .map_err(MycError::from),
   1177     }
   1178 }
   1179 
   1180 fn load_existing_runtime_audit_record_count(
   1181     config: &MycConfig,
   1182     audit_dir: &std::path::Path,
   1183 ) -> Result<usize, MycError> {
   1184     match config.persistence.runtime_audit_backend {
   1185         MycRuntimeAuditBackend::JsonlFile => Ok(MycJsonlOperationAuditStore::new(
   1186             audit_dir,
   1187             config.audit.clone(),
   1188         )
   1189         .list_all()?
   1190         .len()),
   1191         MycRuntimeAuditBackend::Sqlite => Ok(MycSqliteOperationAuditStore::open(
   1192             audit_dir,
   1193             config.audit.clone(),
   1194         )?
   1195         .list_all()?
   1196         .len()),
   1197     }
   1198 }
   1199 
   1200 fn verify_restored_delivery_state(
   1201     signer_state: &RadrootsNostrSignerStoreState,
   1202     outbox_records: &[MycDeliveryOutboxRecord],
   1203     signer_public_key: PublicKey,
   1204     discovery_app_public_key: Option<PublicKey>,
   1205 ) -> Result<(), MycError> {
   1206     let connections_by_id = signer_state
   1207         .connections
   1208         .iter()
   1209         .map(|connection| (connection.connection_id.as_str().to_owned(), connection))
   1210         .collect::<BTreeMap<_, _>>();
   1211     let workflows_by_id = signer_state
   1212         .publish_workflows
   1213         .iter()
   1214         .map(|workflow| (workflow.workflow_id.as_str().to_owned(), workflow))
   1215         .collect::<BTreeMap<_, _>>();
   1216     let mut referenced_unfinished_workflow_ids = BTreeSet::new();
   1217 
   1218     for record in outbox_records {
   1219         verify_discovery_restore_author(record, signer_public_key, discovery_app_public_key)?;
   1220 
   1221         if !matches!(
   1222             record.status,
   1223             MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize
   1224         ) {
   1225             continue;
   1226         }
   1227 
   1228         let workflow = match record.signer_publish_workflow_id.as_ref() {
   1229             Some(workflow_id) => {
   1230                 referenced_unfinished_workflow_ids.insert(workflow_id.as_str().to_owned());
   1231                 workflows_by_id.get(workflow_id.as_str()).copied()
   1232             }
   1233             None => None,
   1234         };
   1235 
   1236         verify_restore_outbox_record(record, workflow, &connections_by_id)?;
   1237     }
   1238 
   1239     let orphaned_workflows = signer_state
   1240         .publish_workflows
   1241         .iter()
   1242         .filter(|workflow| {
   1243             !referenced_unfinished_workflow_ids.contains(workflow.workflow_id.as_str())
   1244         })
   1245         .map(|workflow| {
   1246             format!(
   1247                 "{}:{}:{:?}",
   1248                 workflow.workflow_id, workflow.connection_id, workflow.kind
   1249             )
   1250         })
   1251         .collect::<Vec<_>>();
   1252     if !orphaned_workflows.is_empty() {
   1253         return Err(MycError::InvalidOperation(format!(
   1254             "persistence verify-restore found orphaned signer publish workflows with no unfinished delivery outbox job: {}",
   1255             orphaned_workflows.join(", ")
   1256         )));
   1257     }
   1258 
   1259     Ok(())
   1260 }
   1261 
   1262 fn verify_discovery_restore_author(
   1263     record: &MycDeliveryOutboxRecord,
   1264     signer_public_key: PublicKey,
   1265     discovery_app_public_key: Option<PublicKey>,
   1266 ) -> Result<(), MycError> {
   1267     if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish {
   1268         return Ok(());
   1269     }
   1270     if record.event.pubkey == signer_public_key
   1271         || discovery_app_public_key == Some(record.event.pubkey)
   1272     {
   1273         return Ok(());
   1274     }
   1275 
   1276     Err(MycError::InvalidOperation(format!(
   1277         "persistence verify-restore found discovery delivery outbox job `{}` authored by `{}` but the configured signer/discovery identities do not match",
   1278         record.job_id, record.event.pubkey
   1279     )))
   1280 }
   1281 
   1282 fn verify_restore_outbox_record<'a>(
   1283     record: &MycDeliveryOutboxRecord,
   1284     workflow: Option<&'a RadrootsNostrSignerPublishWorkflowRecord>,
   1285     connections_by_id: &BTreeMap<String, &'a RadrootsNostrSignerConnectionRecord>,
   1286 ) -> Result<(), MycError> {
   1287     match record.kind {
   1288         MycDeliveryOutboxKind::DiscoveryHandlerPublish => {
   1289             if record.signer_publish_workflow_id.is_some() {
   1290                 return Err(MycError::InvalidOperation(format!(
   1291                     "persistence verify-restore found discovery delivery outbox job `{}` that incorrectly references a signer publish workflow",
   1292                     record.job_id
   1293                 )));
   1294             }
   1295         }
   1296         MycDeliveryOutboxKind::ConnectAcceptPublish | MycDeliveryOutboxKind::AuthReplayPublish => {
   1297             if record.signer_publish_workflow_id.is_none() {
   1298                 return Err(MycError::InvalidOperation(format!(
   1299                     "persistence verify-restore found control delivery outbox job `{}` without a signer publish workflow",
   1300                     record.job_id
   1301                 )));
   1302             }
   1303         }
   1304         MycDeliveryOutboxKind::ListenerResponsePublish => {}
   1305     }
   1306 
   1307     match workflow {
   1308         Some(workflow) => {
   1309             let expected_kind = match record.kind {
   1310                 MycDeliveryOutboxKind::ListenerResponsePublish
   1311                 | MycDeliveryOutboxKind::ConnectAcceptPublish => {
   1312                     RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
   1313                 }
   1314                 MycDeliveryOutboxKind::AuthReplayPublish => {
   1315                     RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
   1316                 }
   1317                 MycDeliveryOutboxKind::DiscoveryHandlerPublish => unreachable!(),
   1318             };
   1319             if workflow.kind != expected_kind {
   1320                 return Err(MycError::InvalidOperation(format!(
   1321                     "persistence verify-restore found delivery outbox job `{}` expecting signer workflow kind `{:?}` but found `{:?}`",
   1322                     record.job_id, expected_kind, workflow.kind
   1323                 )));
   1324             }
   1325 
   1326             let connection_id = record.connection_id.as_ref().ok_or_else(|| {
   1327                 MycError::InvalidOperation(format!(
   1328                     "persistence verify-restore found delivery outbox job `{}` missing a connection id required for signer workflow verification",
   1329                     record.job_id
   1330                 ))
   1331             })?;
   1332             if workflow.connection_id.as_str() != connection_id.as_str() {
   1333                 return Err(MycError::InvalidOperation(format!(
   1334                     "persistence verify-restore found delivery outbox job `{}` bound to connection `{connection_id}` but signer workflow `{}` is bound to `{}`",
   1335                     record.job_id, workflow.workflow_id, workflow.connection_id
   1336                 )));
   1337             }
   1338             if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize
   1339                 && workflow.state
   1340                     != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
   1341             {
   1342                 return Err(MycError::InvalidOperation(format!(
   1343                     "persistence verify-restore found delivery outbox job `{}` waiting for finalize but signer workflow `{}` is in `{:?}`",
   1344                     record.job_id, workflow.workflow_id, workflow.state
   1345                 )));
   1346             }
   1347         }
   1348         None => {
   1349             if record.signer_publish_workflow_id.is_some() {
   1350                 if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize {
   1351                     verify_already_finalized_without_workflow(record, connections_by_id)?;
   1352                 } else {
   1353                     return Err(MycError::InvalidOperation(format!(
   1354                         "persistence verify-restore found delivery outbox job `{}` referencing a missing signer publish workflow before finalize",
   1355                         record.job_id
   1356                     )));
   1357                 }
   1358             }
   1359         }
   1360     }
   1361 
   1362     Ok(())
   1363 }
   1364 
   1365 fn verify_already_finalized_without_workflow(
   1366     record: &MycDeliveryOutboxRecord,
   1367     connections_by_id: &BTreeMap<String, &RadrootsNostrSignerConnectionRecord>,
   1368 ) -> Result<(), MycError> {
   1369     let workflow_id = record.signer_publish_workflow_id.as_ref().ok_or_else(|| {
   1370         MycError::InvalidOperation(format!(
   1371             "persistence verify-restore found delivery outbox job `{}` missing a signer workflow id for finalization verification",
   1372             record.job_id
   1373         ))
   1374     })?;
   1375     let connection_id = record.connection_id.as_ref().ok_or_else(|| {
   1376         MycError::InvalidOperation(format!(
   1377             "persistence verify-restore found delivery outbox job `{}` missing a connection id for finalization verification",
   1378             record.job_id
   1379         ))
   1380     })?;
   1381     let connection = connections_by_id
   1382         .get(connection_id.as_str())
   1383         .copied()
   1384         .ok_or_else(|| {
   1385             MycError::InvalidOperation(format!(
   1386                 "persistence verify-restore found delivery outbox job `{}` referencing missing connection `{connection_id}`",
   1387                 record.job_id
   1388             ))
   1389         })?;
   1390 
   1391     match record.kind {
   1392         MycDeliveryOutboxKind::ListenerResponsePublish
   1393         | MycDeliveryOutboxKind::ConnectAcceptPublish => {
   1394             if !connection.connect_secret_is_consumed() {
   1395                 return Err(MycError::InvalidOperation(format!(
   1396                     "persistence verify-restore found delivery outbox job `{}` referencing connect workflow `{workflow_id}` but the connection secret is still reusable",
   1397                     record.job_id
   1398                 )));
   1399             }
   1400         }
   1401         MycDeliveryOutboxKind::AuthReplayPublish => {
   1402             if connection.auth_state != RadrootsNostrSignerAuthState::Authorized
   1403                 || connection.pending_request.is_some()
   1404             {
   1405                 return Err(MycError::InvalidOperation(format!(
   1406                     "persistence verify-restore found delivery outbox job `{}` referencing auth replay workflow `{workflow_id}` but the connection auth state is not finalized",
   1407                     record.job_id
   1408                 )));
   1409             }
   1410         }
   1411         MycDeliveryOutboxKind::DiscoveryHandlerPublish => {
   1412             return Err(MycError::InvalidOperation(format!(
   1413                 "persistence verify-restore found discovery delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`",
   1414                 record.job_id
   1415             )));
   1416         }
   1417     }
   1418 
   1419     Ok(())
   1420 }
   1421 #[cfg(test)]
   1422 mod tests {
   1423     use std::path::{Path, PathBuf};
   1424 
   1425     use nostr::PublicKey;
   1426     use radroots_identity::RadrootsIdentity;
   1427     use radroots_nostr::prelude::{
   1428         RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKind,
   1429     };
   1430     use radroots_nostr_signer::prelude::{
   1431         RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrFileSignerStore,
   1432         RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId,
   1433         RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
   1434         RadrootsNostrSqliteSignerStore,
   1435     };
   1436 
   1437     use super::{
   1438         MycPersistenceImportSelection, import_json_to_sqlite, signer_store_state_is_empty,
   1439         verify_restored_delivery_state,
   1440     };
   1441     use crate::app::MycRuntime;
   1442     use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
   1443     use crate::audit_sqlite::MycSqliteOperationAuditStore;
   1444     use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend};
   1445     use crate::error::MycError;
   1446     use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord};
   1447 
   1448     const SIGNER_SECRET_KEY: &str =
   1449         "1111111111111111111111111111111111111111111111111111111111111111";
   1450     const USER_SECRET_KEY: &str =
   1451         "2222222222222222222222222222222222222222222222222222222222222222";
   1452     const OTHER_SECRET_KEY: &str =
   1453         "3333333333333333333333333333333333333333333333333333333333333333";
   1454 
   1455     fn write_identity(path: &Path, secret_key: &str) {
   1456         let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
   1457         crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
   1458     }
   1459 
   1460     fn identity(secret_key: &str) -> RadrootsIdentity {
   1461         RadrootsIdentity::from_secret_key_str(secret_key).expect("identity")
   1462     }
   1463 
   1464     fn signer_identity() -> RadrootsIdentity {
   1465         identity(SIGNER_SECRET_KEY)
   1466     }
   1467 
   1468     fn user_identity() -> RadrootsIdentity {
   1469         identity(USER_SECRET_KEY)
   1470     }
   1471 
   1472     fn signed_event(secret_key: &str) -> RadrootsNostrEvent {
   1473         RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello")
   1474             .sign_with_keys(identity(secret_key).keys())
   1475             .expect("sign event")
   1476     }
   1477 
   1478     fn outbox_record(kind: MycDeliveryOutboxKind, secret_key: &str) -> MycDeliveryOutboxRecord {
   1479         MycDeliveryOutboxRecord::new(
   1480             kind,
   1481             signed_event(secret_key),
   1482             vec!["wss://relay.example.com".parse().expect("relay")],
   1483         )
   1484         .expect("record")
   1485     }
   1486 
   1487     fn client_public_key(value: &str) -> PublicKey {
   1488         PublicKey::from_hex(value).expect("pubkey")
   1489     }
   1490 
   1491     fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState {
   1492         RadrootsNostrFileSignerStore::new(temp.join("state").join("signer-state.json"))
   1493             .load()
   1494             .expect("load signer state")
   1495     }
   1496 
   1497     fn empty_signer_state() -> RadrootsNostrSignerStoreState {
   1498         RadrootsNostrSignerStoreState {
   1499             version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
   1500             signer_identity: None,
   1501             connections: Vec::new(),
   1502             audit_records: Vec::new(),
   1503             publish_workflows: Vec::new(),
   1504         }
   1505     }
   1506 
   1507     fn base_config(temp: &Path) -> MycConfig {
   1508         let mut config = MycConfig::default();
   1509         config.paths.state_dir = temp.join("state");
   1510         config.paths.signer_identity_path = temp.join("signer.json");
   1511         config.paths.user_identity_path = temp.join("user.json");
   1512         write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY);
   1513         write_identity(&config.paths.user_identity_path, USER_SECRET_KEY);
   1514         config
   1515     }
   1516 
   1517     fn bootstrap_json_runtime(temp: &Path) -> MycRuntime {
   1518         let config = base_config(temp);
   1519         MycRuntime::bootstrap(config).expect("runtime")
   1520     }
   1521 
   1522     #[test]
   1523     fn signer_store_state_is_not_empty_when_only_publish_workflows_are_present() {
   1524         let workflow = radroots_nostr_signer::prelude::RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization(
   1525             RadrootsNostrSignerConnectionId::parse("workflow-only-connection")
   1526                 .expect("workflow connection id"),
   1527             17,
   1528         );
   1529         let state = RadrootsNostrSignerStoreState {
   1530             version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
   1531             signer_identity: None,
   1532             connections: Vec::new(),
   1533             audit_records: Vec::new(),
   1534             publish_workflows: vec![workflow],
   1535         };
   1536 
   1537         assert!(
   1538             !signer_store_state_is_empty(&state),
   1539             "publish workflows must make the signer-state destination non-empty"
   1540         );
   1541     }
   1542 
   1543     #[test]
   1544     fn verify_restore_rejects_orphaned_signer_publish_workflows() {
   1545         let temp = tempfile::tempdir().expect("tempdir");
   1546         let runtime = bootstrap_json_runtime(temp.path());
   1547         let manager = runtime.signer_manager().expect("manager");
   1548         let connection = manager
   1549             .register_connection(
   1550                 RadrootsNostrSignerConnectionDraft::new(
   1551                     client_public_key(
   1552                         "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
   1553                     ),
   1554                     runtime.user_public_identity(),
   1555                 )
   1556                 .with_connect_secret("orphan-secret"),
   1557             )
   1558             .expect("register connection");
   1559         manager
   1560             .begin_connect_secret_publish_finalization(&connection.connection_id)
   1561             .expect("begin workflow");
   1562 
   1563         let signer_state = load_json_signer_state(temp.path());
   1564         let err = verify_restored_delivery_state(
   1565             &signer_state,
   1566             &[],
   1567             signer_identity().public_key(),
   1568             None,
   1569         )
   1570         .expect_err("orphaned workflow should fail restore verification");
   1571 
   1572         assert!(
   1573             err.to_string()
   1574                 .contains("orphaned signer publish workflows")
   1575         );
   1576     }
   1577 
   1578     #[test]
   1579     fn verify_restore_rejects_discovery_author_mismatch() {
   1580         let signer_state = empty_signer_state();
   1581         let record = outbox_record(
   1582             MycDeliveryOutboxKind::DiscoveryHandlerPublish,
   1583             OTHER_SECRET_KEY,
   1584         );
   1585 
   1586         let err = verify_restored_delivery_state(
   1587             &signer_state,
   1588             &[record],
   1589             signer_identity().public_key(),
   1590             Some(user_identity().public_key()),
   1591         )
   1592         .expect_err("unexpected discovery author should fail restore verification");
   1593 
   1594         assert!(
   1595             err.to_string()
   1596                 .contains("configured signer/discovery identities do not match")
   1597         );
   1598     }
   1599 
   1600     #[test]
   1601     fn verify_restore_rejects_missing_workflow_before_finalize() {
   1602         let signer_state = empty_signer_state();
   1603         let workflow_id =
   1604             RadrootsNostrSignerWorkflowId::parse("missing-workflow").expect("workflow id");
   1605         let record = outbox_record(
   1606             MycDeliveryOutboxKind::ListenerResponsePublish,
   1607             SIGNER_SECRET_KEY,
   1608         )
   1609         .with_signer_publish_workflow_id(&workflow_id);
   1610 
   1611         let err = verify_restored_delivery_state(
   1612             &signer_state,
   1613             &[record],
   1614             signer_identity().public_key(),
   1615             None,
   1616         )
   1617         .expect_err("missing unfinished workflow should fail restore verification");
   1618 
   1619         assert!(
   1620             err.to_string()
   1621                 .contains("referencing a missing signer publish workflow before finalize")
   1622         );
   1623     }
   1624 
   1625     #[test]
   1626     fn verify_restore_accepts_published_pending_finalize_job_after_connect_finalization() {
   1627         let temp = tempfile::tempdir().expect("tempdir");
   1628         let runtime = bootstrap_json_runtime(temp.path());
   1629         let manager = runtime.signer_manager().expect("manager");
   1630         let connection = manager
   1631             .register_connection(
   1632                 RadrootsNostrSignerConnectionDraft::new(
   1633                     client_public_key(
   1634                         "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f",
   1635                     ),
   1636                     runtime.user_public_identity(),
   1637                 )
   1638                 .with_connect_secret("accepted-secret"),
   1639             )
   1640             .expect("register connection");
   1641         let workflow = manager
   1642             .begin_connect_secret_publish_finalization(&connection.connection_id)
   1643             .expect("begin workflow");
   1644         manager
   1645             .mark_publish_workflow_published(&workflow.workflow_id)
   1646             .expect("mark published");
   1647         manager
   1648             .finalize_publish_workflow(&workflow.workflow_id)
   1649             .expect("finalize workflow");
   1650 
   1651         let signer_state = load_json_signer_state(temp.path());
   1652         let mut record = outbox_record(
   1653             MycDeliveryOutboxKind::ListenerResponsePublish,
   1654             SIGNER_SECRET_KEY,
   1655         )
   1656         .with_connection_id(&connection.connection_id)
   1657         .with_signer_publish_workflow_id(&workflow.workflow_id);
   1658         record
   1659             .mark_published_pending_finalize(1, record.created_at_unix + 1)
   1660             .expect("mark published");
   1661 
   1662         verify_restored_delivery_state(
   1663             &signer_state,
   1664             &[record],
   1665             signer_identity().public_key(),
   1666             None,
   1667         )
   1668         .expect("already-finalized connect workflow should be accepted");
   1669     }
   1670 
   1671     #[test]
   1672     fn verify_restore_rejects_wrong_workflow_kind() {
   1673         let temp = tempfile::tempdir().expect("tempdir");
   1674         let runtime = bootstrap_json_runtime(temp.path());
   1675         let manager = runtime.signer_manager().expect("manager");
   1676         let connection = manager
   1677             .register_connection(
   1678                 RadrootsNostrSignerConnectionDraft::new(
   1679                     client_public_key(
   1680                         "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5",
   1681                     ),
   1682                     runtime.user_public_identity(),
   1683                 )
   1684                 .with_connect_secret("kind-secret"),
   1685             )
   1686             .expect("register connection");
   1687         let workflow = manager
   1688             .begin_connect_secret_publish_finalization(&connection.connection_id)
   1689             .expect("begin workflow");
   1690 
   1691         let signer_state = load_json_signer_state(temp.path());
   1692         let record = outbox_record(MycDeliveryOutboxKind::AuthReplayPublish, SIGNER_SECRET_KEY)
   1693             .with_connection_id(&connection.connection_id)
   1694             .with_signer_publish_workflow_id(&workflow.workflow_id);
   1695 
   1696         let err = verify_restored_delivery_state(
   1697             &signer_state,
   1698             &[record],
   1699             signer_identity().public_key(),
   1700             None,
   1701         )
   1702         .expect_err("workflow kind mismatch should fail restore verification");
   1703 
   1704         assert!(err.to_string().contains("expecting signer workflow kind"));
   1705     }
   1706 
   1707     #[test]
   1708     fn verify_restore_rejects_wrong_connection_binding() {
   1709         let temp = tempfile::tempdir().expect("tempdir");
   1710         let runtime = bootstrap_json_runtime(temp.path());
   1711         let manager = runtime.signer_manager().expect("manager");
   1712         let first = manager
   1713             .register_connection(
   1714                 RadrootsNostrSignerConnectionDraft::new(
   1715                     client_public_key(
   1716                         "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
   1717                     ),
   1718                     runtime.user_public_identity(),
   1719                 )
   1720                 .with_connect_secret("first-secret"),
   1721             )
   1722             .expect("register first");
   1723         let second = manager
   1724             .register_connection(
   1725                 RadrootsNostrSignerConnectionDraft::new(
   1726                     client_public_key(
   1727                         "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f",
   1728                     ),
   1729                     runtime.user_public_identity(),
   1730                 )
   1731                 .with_connect_secret("second-secret"),
   1732             )
   1733             .expect("register second");
   1734         let workflow = manager
   1735             .begin_connect_secret_publish_finalization(&first.connection_id)
   1736             .expect("begin workflow");
   1737 
   1738         let signer_state = load_json_signer_state(temp.path());
   1739         let record = outbox_record(
   1740             MycDeliveryOutboxKind::ListenerResponsePublish,
   1741             SIGNER_SECRET_KEY,
   1742         )
   1743         .with_connection_id(&second.connection_id)
   1744         .with_signer_publish_workflow_id(&workflow.workflow_id);
   1745 
   1746         let err = verify_restored_delivery_state(
   1747             &signer_state,
   1748             &[record],
   1749             signer_identity().public_key(),
   1750             None,
   1751         )
   1752         .expect_err("workflow connection mismatch should fail restore verification");
   1753 
   1754         assert!(err.to_string().contains("is bound to"));
   1755     }
   1756 
   1757     #[test]
   1758     fn verify_restore_rejects_missing_connection_id_for_workflow_job() {
   1759         let temp = tempfile::tempdir().expect("tempdir");
   1760         let runtime = bootstrap_json_runtime(temp.path());
   1761         let manager = runtime.signer_manager().expect("manager");
   1762         let connection = manager
   1763             .register_connection(
   1764                 RadrootsNostrSignerConnectionDraft::new(
   1765                     client_public_key(
   1766                         "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5",
   1767                     ),
   1768                     runtime.user_public_identity(),
   1769                 )
   1770                 .with_connect_secret("missing-connection-id-secret"),
   1771             )
   1772             .expect("register connection");
   1773         let workflow = manager
   1774             .begin_connect_secret_publish_finalization(&connection.connection_id)
   1775             .expect("begin workflow");
   1776 
   1777         let signer_state = load_json_signer_state(temp.path());
   1778         let record = outbox_record(
   1779             MycDeliveryOutboxKind::ListenerResponsePublish,
   1780             SIGNER_SECRET_KEY,
   1781         )
   1782         .with_signer_publish_workflow_id(&workflow.workflow_id);
   1783 
   1784         let err = verify_restored_delivery_state(
   1785             &signer_state,
   1786             &[record],
   1787             signer_identity().public_key(),
   1788             None,
   1789         )
   1790         .expect_err("missing connection id should fail restore verification");
   1791 
   1792         assert!(
   1793             err.to_string()
   1794                 .contains("missing a connection id required for signer workflow verification")
   1795         );
   1796     }
   1797 
   1798     #[test]
   1799     fn import_json_to_sqlite_moves_signer_state_and_runtime_audit() {
   1800         let temp = tempfile::tempdir().expect("tempdir");
   1801         let runtime = bootstrap_json_runtime(temp.path());
   1802         let manager = runtime.signer_manager().expect("manager");
   1803         let connection = manager
   1804             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1805                 PublicKey::from_hex(
   1806                     "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
   1807                 )
   1808                 .expect("pubkey"),
   1809                 runtime.user_public_identity(),
   1810             ))
   1811             .expect("register connection");
   1812         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1813             MycOperationAuditKind::ListenerResponsePublish,
   1814             MycOperationAuditOutcome::Succeeded,
   1815             Some(&connection.connection_id),
   1816             Some("request-1"),
   1817             1,
   1818             1,
   1819             "publish succeeded",
   1820         ));
   1821 
   1822         let mut sqlite_config = base_config(temp.path());
   1823         sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
   1824         sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite;
   1825 
   1826         let output = import_json_to_sqlite(
   1827             &sqlite_config,
   1828             MycPersistenceImportSelection::new(false, false),
   1829         )
   1830         .expect("import");
   1831 
   1832         assert_eq!(
   1833             output
   1834                 .signer_state
   1835                 .as_ref()
   1836                 .expect("signer-state output")
   1837                 .connection_count,
   1838             1
   1839         );
   1840         assert_eq!(
   1841             output
   1842                 .runtime_audit
   1843                 .as_ref()
   1844                 .expect("runtime-audit output")
   1845                 .record_count,
   1846             1
   1847         );
   1848 
   1849         let imported_runtime = MycRuntime::bootstrap(sqlite_config).expect("sqlite runtime");
   1850         assert_eq!(
   1851             imported_runtime
   1852                 .signer_manager()
   1853                 .expect("manager")
   1854                 .list_connections()
   1855                 .expect("connections")
   1856                 .len(),
   1857             1
   1858         );
   1859         assert_eq!(
   1860             imported_runtime
   1861                 .operation_audit_store()
   1862                 .list_all()
   1863                 .expect("audit records")
   1864                 .len(),
   1865             1
   1866         );
   1867     }
   1868 
   1869     #[test]
   1870     fn import_signer_state_rejects_non_empty_sqlite_destination() {
   1871         let temp = tempfile::tempdir().expect("tempdir");
   1872         let runtime = bootstrap_json_runtime(temp.path());
   1873         let manager = runtime.signer_manager().expect("manager");
   1874         manager
   1875             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1876                 PublicKey::from_hex(
   1877                     "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
   1878                 )
   1879                 .expect("pubkey"),
   1880                 runtime.user_public_identity(),
   1881             ))
   1882             .expect("register connection");
   1883 
   1884         let mut sqlite_config = base_config(temp.path());
   1885         sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
   1886 
   1887         let sqlite_store = RadrootsNostrSqliteSignerStore::open(
   1888             temp.path().join("state").join("signer-state.sqlite"),
   1889         )
   1890         .expect("sqlite store");
   1891         let existing_state =
   1892             RadrootsNostrFileSignerStore::new(temp.path().join("state").join("signer-state.json"))
   1893                 .load()
   1894                 .expect("load source state");
   1895         sqlite_store
   1896             .save(&existing_state)
   1897             .expect("save sqlite state");
   1898 
   1899         let err = import_json_to_sqlite(
   1900             &sqlite_config,
   1901             MycPersistenceImportSelection::new(true, false),
   1902         )
   1903         .expect_err("non-empty sqlite signer destination should fail");
   1904 
   1905         assert!(err.to_string().contains("sqlite signer-state destination"));
   1906     }
   1907 
   1908     #[test]
   1909     fn import_runtime_audit_rejects_non_empty_sqlite_destination() {
   1910         let temp = tempfile::tempdir().expect("tempdir");
   1911         let runtime = bootstrap_json_runtime(temp.path());
   1912         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1913             MycOperationAuditKind::ListenerResponsePublish,
   1914             MycOperationAuditOutcome::Succeeded,
   1915             None,
   1916             Some("request-1"),
   1917             1,
   1918             1,
   1919             "publish succeeded",
   1920         ));
   1921 
   1922         let mut sqlite_config = base_config(temp.path());
   1923         sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite;
   1924 
   1925         let sqlite_audit_store = MycSqliteOperationAuditStore::open(
   1926             temp.path().join("state").join("audit"),
   1927             sqlite_config.audit.clone(),
   1928         )
   1929         .expect("sqlite audit store");
   1930         sqlite_audit_store
   1931             .append(&MycOperationAuditRecord::new(
   1932                 MycOperationAuditKind::AuthReplayRestore,
   1933                 MycOperationAuditOutcome::Restored,
   1934                 None,
   1935                 Some("request-2"),
   1936                 1,
   1937                 0,
   1938                 "restored pending auth challenge",
   1939             ))
   1940             .expect("append");
   1941 
   1942         let err = import_json_to_sqlite(
   1943             &sqlite_config,
   1944             MycPersistenceImportSelection::new(false, true),
   1945         )
   1946         .expect_err("non-empty sqlite audit destination should fail");
   1947 
   1948         assert!(err.to_string().contains("sqlite runtime-audit destination"));
   1949     }
   1950 
   1951     #[test]
   1952     fn import_signer_state_rejects_mismatched_configured_signer_identity() {
   1953         let temp = tempfile::tempdir().expect("tempdir");
   1954         let runtime = bootstrap_json_runtime(temp.path());
   1955         let manager = runtime.signer_manager().expect("manager");
   1956         manager
   1957             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1958                 PublicKey::from_hex(
   1959                     "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
   1960                 )
   1961                 .expect("pubkey"),
   1962                 runtime.user_public_identity(),
   1963             ))
   1964             .expect("register connection");
   1965 
   1966         let mut sqlite_config = base_config(temp.path());
   1967         let other_signer_path = PathBuf::from(temp.path()).join("other-signer.json");
   1968         write_identity(
   1969             &other_signer_path,
   1970             "3333333333333333333333333333333333333333333333333333333333333333",
   1971         );
   1972         sqlite_config.paths.signer_identity_path = other_signer_path;
   1973         sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
   1974 
   1975         let err = import_json_to_sqlite(
   1976             &sqlite_config,
   1977             MycPersistenceImportSelection::new(true, false),
   1978         )
   1979         .expect_err("mismatched signer identity should fail");
   1980 
   1981         assert!(matches!(err, MycError::SignerIdentityImportMismatch { .. }));
   1982     }
   1983 }