myc

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

commit 86bea74eba9df4e512e5bd3494a635eb7492362b
parent 14ff72b26eeba3d89a1cf7a161188316034caee3
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 16:38:45 +0000

migration: add json to sqlite import command

Diffstat:
Msrc/app/runtime.rs | 24+++++++++++++++++++-----
Msrc/cli.rs | 27+++++++++++++++++++++++++++
Msrc/error.rs | 8++++++++
Msrc/lib.rs | 5+++++
Asrc/persistence.rs | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 499 insertions(+), 5 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -288,16 +288,30 @@ async fn observe_shutdown_signal(mut shutdown_rx: tokio::sync::watch::Receiver<b } impl MycRuntimePaths { + pub(crate) fn audit_dir_for_state_dir(state_dir: &Path) -> PathBuf { + state_dir.join("audit") + } + + pub(crate) fn signer_state_path_for_backend( + state_dir: &Path, + backend: MycSignerStateBackend, + ) -> PathBuf { + state_dir.join(match backend { + MycSignerStateBackend::JsonFile => "signer-state.json", + MycSignerStateBackend::Sqlite => "signer-state.sqlite", + }) + } + fn from_config(config: &MycConfig) -> Self { let state_dir = config.paths.state_dir.clone(); Self { signer_identity_path: config.paths.signer_identity_path.clone(), user_identity_path: config.paths.user_identity_path.clone(), - signer_state_path: state_dir.join(match config.persistence.signer_state_backend { - MycSignerStateBackend::JsonFile => "signer-state.json", - MycSignerStateBackend::Sqlite => "signer-state.sqlite", - }), - audit_dir: state_dir.join("audit"), + signer_state_path: Self::signer_state_path_for_backend( + &state_dir, + config.persistence.signer_state_backend, + ), + audit_dir: Self::audit_dir_for_state_dir(&state_dir), state_dir, } } diff --git a/src/cli.rs b/src/cli.rs @@ -24,6 +24,7 @@ use crate::operability::{ collect_metrics, collect_status_full, collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation, operation_kind_label, render_metrics_text, }; +use crate::persistence::{MycPersistenceImportSelection, import_json_to_sqlite}; #[derive(Debug, Parser)] #[command(name = "myc")] @@ -46,6 +47,10 @@ pub enum MycCommand { #[arg(long, value_enum, default_value_t = MycMetricsFormat::Prometheus)] format: MycMetricsFormat, }, + Persistence { + #[command(subcommand)] + command: MycPersistenceCommand, + }, Connections { #[command(subcommand)] command: MycConnectionsCommand, @@ -77,6 +82,16 @@ pub enum MycConnectionsCommand { } #[derive(Debug, Subcommand)] +pub enum MycPersistenceCommand { + ImportJsonToSqlite { + #[arg(long)] + signer_state: bool, + #[arg(long)] + runtime_audit: bool, + }, +} + +#[derive(Debug, Subcommand)] pub enum MycAuditCommand { List { #[arg(long)] @@ -303,6 +318,18 @@ pub async fn run_from_env() -> Result<(), MycError> { } } } + MycCommand::Persistence { command } => match command { + MycPersistenceCommand::ImportJsonToSqlite { + signer_state, + runtime_audit, + } => { + let output = import_json_to_sqlite( + &config, + MycPersistenceImportSelection::new(signer_state, runtime_audit), + )?; + print_json(&output) + } + }, MycCommand::Connections { command } => { let runtime = MycRuntime::bootstrap(config)?; match command { diff --git a/src/error.rs b/src/error.rs @@ -183,6 +183,14 @@ pub enum MycError { configured_identity_id: String, persisted_identity_id: String, }, + #[error( + "configured signer identity `{configured_identity_id}` does not match imported signer identity `{imported_identity_id}` from {state_path}" + )] + SignerIdentityImportMismatch { + state_path: PathBuf, + configured_identity_id: String, + imported_identity_id: String, + }, } impl MycError { diff --git a/src/lib.rs b/src/lib.rs @@ -11,6 +11,7 @@ pub mod discovery; pub mod error; pub mod logging; pub mod operability; +pub mod persistence; pub mod policy; pub mod transport; @@ -47,6 +48,10 @@ pub use operability::{ MycStatusFullOutput, MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, collect_status_summary, render_metrics_text, }; +pub use persistence::{ + MycPersistenceImportJsonToSqliteOutput, MycPersistenceImportSelection, + MycRuntimeAuditImportOutput, MycSignerStateImportOutput, import_json_to_sqlite, +}; pub use policy::{MycConnectDecision, MycPolicyContext}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/persistence.rs b/src/persistence.rs @@ -0,0 +1,440 @@ +use std::fs; +use std::path::PathBuf; + +use radroots_nostr_signer::prelude::{ + RadrootsNostrFileSignerStore, RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore, +}; +use serde::Serialize; + +use crate::app::MycRuntimePaths; +use crate::audit::MycJsonlOperationAuditStore; +use crate::audit_sqlite::MycSqliteOperationAuditStore; +use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; +use crate::custody::MycIdentityProvider; +use crate::error::MycError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MycPersistenceImportSelection { + import_signer_state: bool, + import_runtime_audit: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceImportJsonToSqliteOutput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_state: Option<MycSignerStateImportOutput>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_audit: Option<MycRuntimeAuditImportOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycSignerStateImportOutput { + pub source_path: PathBuf, + pub destination_path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_identity_id: Option<String>, + pub connection_count: usize, + pub request_audit_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRuntimeAuditImportOutput { + pub source_dir: PathBuf, + pub destination_path: PathBuf, + pub record_count: usize, +} + +impl MycPersistenceImportSelection { + pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self { + Self { + import_signer_state, + import_runtime_audit, + } + } + + fn resolve(self, config: &MycConfig) -> Result<Self, MycError> { + let import_signer_state = if self.import_signer_state || self.import_runtime_audit { + self.import_signer_state + } else { + config.persistence.signer_state_backend == MycSignerStateBackend::Sqlite + }; + let import_runtime_audit = if self.import_signer_state || self.import_runtime_audit { + self.import_runtime_audit + } else { + config.persistence.runtime_audit_backend == MycRuntimeAuditBackend::Sqlite + }; + + if import_signer_state + && config.persistence.signer_state_backend != MycSignerStateBackend::Sqlite + { + return Err(MycError::InvalidOperation( + "json-to-sqlite signer-state import requires MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite" + .to_owned(), + )); + } + if import_runtime_audit + && config.persistence.runtime_audit_backend != MycRuntimeAuditBackend::Sqlite + { + return Err(MycError::InvalidOperation( + "json-to-sqlite runtime-audit import requires MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite" + .to_owned(), + )); + } + if !import_signer_state && !import_runtime_audit { + return Err(MycError::InvalidOperation( + "json-to-sqlite import requires at least one sqlite-backed destination".to_owned(), + )); + } + + Ok(Self { + import_signer_state, + import_runtime_audit, + }) + } +} + +pub fn import_json_to_sqlite( + config: &MycConfig, + selection: MycPersistenceImportSelection, +) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> { + config.validate()?; + let selection = selection.resolve(config)?; + let state_dir = &config.paths.state_dir; + let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); + fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir { + path: state_dir.clone(), + source, + })?; + fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir { + path: audit_dir.clone(), + source, + })?; + let mut output = MycPersistenceImportJsonToSqliteOutput { + signer_state: None, + runtime_audit: None, + }; + + if selection.import_signer_state { + output.signer_state = Some(import_signer_state_json_to_sqlite(config)?); + } + if selection.import_runtime_audit { + output.runtime_audit = Some(import_runtime_audit_jsonl_to_sqlite(config, &audit_dir)?); + } + + Ok(output) +} + +fn import_signer_state_json_to_sqlite( + config: &MycConfig, +) -> Result<MycSignerStateImportOutput, MycError> { + let source_path = MycRuntimePaths::signer_state_path_for_backend( + &config.paths.state_dir, + MycSignerStateBackend::JsonFile, + ); + let destination_path = MycRuntimePaths::signer_state_path_for_backend( + &config.paths.state_dir, + MycSignerStateBackend::Sqlite, + ); + let source_store = RadrootsNostrFileSignerStore::new(&source_path); + let source_state = source_store.load()?; + let signer_identity_provider = + MycIdentityProvider::from_source("signer", config.paths.signer_identity_source())?; + let configured_signer_identity = signer_identity_provider.load_identity()?.to_public(); + if let Some(imported_signer_identity) = source_state.signer_identity.as_ref() { + if imported_signer_identity.id != configured_signer_identity.id { + return Err(MycError::SignerIdentityImportMismatch { + state_path: source_path.clone(), + configured_identity_id: configured_signer_identity.id.to_string(), + imported_identity_id: imported_signer_identity.id.to_string(), + }); + } + } + + let destination_store = RadrootsNostrSqliteSignerStore::open(&destination_path)?; + let existing_destination_state = destination_store.load()?; + if !signer_store_state_is_empty(&existing_destination_state) { + return Err(MycError::InvalidOperation(format!( + "sqlite signer-state destination {} is not empty; refusing import", + destination_path.display() + ))); + } + + destination_store.save(&source_state)?; + + Ok(MycSignerStateImportOutput { + source_path, + destination_path, + signer_identity_id: source_state + .signer_identity + .as_ref() + .map(|identity| identity.id.to_string()), + connection_count: source_state.connections.len(), + request_audit_count: source_state.audit_records.len(), + }) +} + +fn import_runtime_audit_jsonl_to_sqlite( + config: &MycConfig, + audit_dir: &std::path::Path, +) -> Result<MycRuntimeAuditImportOutput, MycError> { + let source_store = MycJsonlOperationAuditStore::new(audit_dir, config.audit.clone()); + let source_records = source_store.list_all()?; + let destination_store = MycSqliteOperationAuditStore::open(audit_dir, config.audit.clone())?; + let existing_destination_records = destination_store.list_all()?; + if !existing_destination_records.is_empty() { + return Err(MycError::InvalidOperation(format!( + "sqlite runtime-audit destination {} is not empty; refusing import", + destination_store.path().display() + ))); + } + for record in &source_records { + destination_store.append(record)?; + } + + Ok(MycRuntimeAuditImportOutput { + source_dir: audit_dir.to_path_buf(), + destination_path: destination_store.path().to_path_buf(), + record_count: source_records.len(), + }) +} + +fn signer_store_state_is_empty( + state: &radroots_nostr_signer::prelude::RadrootsNostrSignerStoreState, +) -> bool { + state.signer_identity.is_none() + && state.connections.is_empty() + && state.audit_records.is_empty() +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use nostr::PublicKey; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_signer::prelude::{ + RadrootsNostrFileSignerStore, RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerStore, + RadrootsNostrSqliteSignerStore, + }; + + use super::{MycPersistenceImportSelection, import_json_to_sqlite}; + use crate::app::MycRuntime; + use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; + use crate::audit_sqlite::MycSqliteOperationAuditStore; + use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; + use crate::error::MycError; + + fn write_identity(path: &Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn base_config(temp: &Path) -> MycConfig { + let mut config = MycConfig::default(); + config.paths.state_dir = temp.join("state"); + config.paths.signer_identity_path = temp.join("signer.json"); + config.paths.user_identity_path = temp.join("user.json"); + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + config + } + + fn bootstrap_json_runtime(temp: &Path) -> MycRuntime { + let config = base_config(temp); + MycRuntime::bootstrap(config).expect("runtime") + } + + #[test] + fn import_json_to_sqlite_moves_signer_state_and_runtime_audit() { + let temp = tempfile::tempdir().expect("tempdir"); + let runtime = bootstrap_json_runtime(temp.path()); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("pubkey"), + runtime.user_public_identity(), + )) + .expect("register connection"); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some("request-1"), + 1, + 1, + "publish succeeded", + )); + + let mut sqlite_config = base_config(temp.path()); + sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; + + let output = import_json_to_sqlite( + &sqlite_config, + MycPersistenceImportSelection::new(false, false), + ) + .expect("import"); + + assert_eq!( + output + .signer_state + .as_ref() + .expect("signer-state output") + .connection_count, + 1 + ); + assert_eq!( + output + .runtime_audit + .as_ref() + .expect("runtime-audit output") + .record_count, + 1 + ); + + let imported_runtime = MycRuntime::bootstrap(sqlite_config).expect("sqlite runtime"); + assert_eq!( + imported_runtime + .signer_manager() + .expect("manager") + .list_connections() + .expect("connections") + .len(), + 1 + ); + assert_eq!( + imported_runtime + .operation_audit_store() + .list_all() + .expect("audit records") + .len(), + 1 + ); + } + + #[test] + fn import_signer_state_rejects_non_empty_sqlite_destination() { + let temp = tempfile::tempdir().expect("tempdir"); + let runtime = bootstrap_json_runtime(temp.path()); + let manager = runtime.signer_manager().expect("manager"); + manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("pubkey"), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let mut sqlite_config = base_config(temp.path()); + sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + + let sqlite_store = RadrootsNostrSqliteSignerStore::open( + temp.path().join("state").join("signer-state.sqlite"), + ) + .expect("sqlite store"); + let existing_state = + RadrootsNostrFileSignerStore::new(temp.path().join("state").join("signer-state.json")) + .load() + .expect("load source state"); + sqlite_store + .save(&existing_state) + .expect("save sqlite state"); + + let err = import_json_to_sqlite( + &sqlite_config, + MycPersistenceImportSelection::new(true, false), + ) + .expect_err("non-empty sqlite signer destination should fail"); + + assert!(err.to_string().contains("sqlite signer-state destination")); + } + + #[test] + fn import_runtime_audit_rejects_non_empty_sqlite_destination() { + let temp = tempfile::tempdir().expect("tempdir"); + let runtime = bootstrap_json_runtime(temp.path()); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Succeeded, + None, + Some("request-1"), + 1, + 1, + "publish succeeded", + )); + + let mut sqlite_config = base_config(temp.path()); + sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; + + let sqlite_audit_store = MycSqliteOperationAuditStore::open( + temp.path().join("state").join("audit"), + sqlite_config.audit.clone(), + ) + .expect("sqlite audit store"); + sqlite_audit_store + .append(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayRestore, + MycOperationAuditOutcome::Restored, + None, + Some("request-2"), + 1, + 0, + "restored pending auth challenge", + )) + .expect("append"); + + let err = import_json_to_sqlite( + &sqlite_config, + MycPersistenceImportSelection::new(false, true), + ) + .expect_err("non-empty sqlite audit destination should fail"); + + assert!(err.to_string().contains("sqlite runtime-audit destination")); + } + + #[test] + fn import_signer_state_rejects_mismatched_configured_signer_identity() { + let temp = tempfile::tempdir().expect("tempdir"); + let runtime = bootstrap_json_runtime(temp.path()); + let manager = runtime.signer_manager().expect("manager"); + manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("pubkey"), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let mut sqlite_config = base_config(temp.path()); + let other_signer_path = PathBuf::from(temp.path()).join("other-signer.json"); + write_identity( + &other_signer_path, + "3333333333333333333333333333333333333333333333333333333333333333", + ); + sqlite_config.paths.signer_identity_path = other_signer_path; + sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + + let err = import_json_to_sqlite( + &sqlite_config, + MycPersistenceImportSelection::new(true, false), + ) + .expect_err("mismatched signer identity should fail"); + + assert!(matches!(err, MycError::SignerIdentityImportMismatch { .. })); + } +}