myc

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

commit 75754382516b726699b43ac708728ebb33dee404
parent 218689b4e5bc62c53cef615d2265d9c0460f2de2
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 14:51:53 +0000

persistence: add explicit backend selection

Diffstat:
M.env.example | 3+++
Msrc/app/runtime.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/audit.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/config.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 7++++---
5 files changed, 264 insertions(+), 32 deletions(-)

diff --git a/.env.example b/.env.example @@ -15,6 +15,9 @@ MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID= MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user MYC_PATHS_USER_IDENTITY_PROFILE_PATH= +MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file +MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file + MYC_AUDIT_DEFAULT_READ_LIMIT=200 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 MYC_AUDIT_MAX_ARCHIVED_FILES=8 diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -2,9 +2,13 @@ use std::fs; use std::future::Future; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::sync::Arc; -use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore}; -use crate::config::{MycAuditConfig, MycConfig, MycIdentitySourceSpec}; +use crate::audit::{MycJsonlOperationAuditStore, MycOperationAuditRecord, MycOperationAuditStore}; +use crate::config::{ + MycAuditConfig, MycConfig, MycIdentitySourceSpec, MycPersistenceConfig, MycRuntimeAuditBackend, + MycSignerStateBackend, +}; use crate::custody::MycIdentityProvider; use crate::error::MycError; use crate::operability::server::run_observability_server; @@ -13,10 +17,9 @@ use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot} use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_signer::prelude::{ RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerManager, + RadrootsNostrSignerManager, RadrootsNostrSignerStore, }; use serde::Serialize; -use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MycRuntimePaths { @@ -53,9 +56,8 @@ pub struct MycSignerContext { user_identity_provider: MycIdentityProvider, signer_identity: RadrootsIdentity, user_identity: RadrootsIdentity, - signer_state_path: PathBuf, - audit_dir: PathBuf, - audit_config: MycAuditConfig, + signer_store: Arc<dyn RadrootsNostrSignerStore>, + operation_audit_store: Arc<dyn MycOperationAuditStore>, policy: MycPolicyContext, connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, } @@ -76,6 +78,7 @@ impl MycRuntime { Self::prepare_filesystem_for(&paths)?; let signer = MycSignerContext::bootstrap( &paths, + &config.persistence, config.audit.clone(), MycPolicyContext::from_config(&config.policy)?, config.paths.signer_identity_source(), @@ -123,7 +126,7 @@ impl MycRuntime { self.transport.as_ref() } - pub fn operation_audit_store(&self) -> MycOperationAuditStore { + pub fn operation_audit_store(&self) -> Arc<dyn MycOperationAuditStore> { self.signer.operation_audit_store() } @@ -330,16 +333,16 @@ impl MycSignerContext { } pub fn load_signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> { - Self::load_signer_manager_from_path(&self.signer_state_path) + Self::load_signer_manager_from_store(self.signer_store.clone()) } - pub fn operation_audit_store(&self) -> MycOperationAuditStore { - MycOperationAuditStore::new(&self.audit_dir, self.audit_config.clone()) + pub fn operation_audit_store(&self) -> Arc<dyn MycOperationAuditStore> { + self.operation_audit_store.clone() } pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { emit_operation_audit_trace(record); - if let Err(error) = self.operation_audit_store().append(record) { + if let Err(error) = self.operation_audit_store.append(record) { tracing::error!( operation = ?record.operation, outcome = ?record.outcome, @@ -369,6 +372,7 @@ impl MycSignerContext { fn bootstrap( paths: &MycRuntimePaths, + persistence: &MycPersistenceConfig, audit_config: MycAuditConfig, policy: MycPolicyContext, signer_identity_source: MycIdentitySourceSpec, @@ -380,7 +384,10 @@ impl MycSignerContext { MycIdentityProvider::from_source("user", user_identity_source)?; let signer_identity = signer_identity_provider.load_identity()?; let user_identity = user_identity_provider.load_identity()?; - let manager = Self::load_signer_manager_from_path(&paths.signer_state_path)?; + let signer_store = Self::build_signer_store(persistence, &paths.signer_state_path); + let operation_audit_store = + Self::build_operation_audit_store(persistence, &paths.audit_dir, audit_config); + let manager = Self::load_signer_manager_from_store(signer_store.clone())?; let configured_public = signer_identity.to_public(); match manager.signer_identity()? { @@ -401,18 +408,38 @@ impl MycSignerContext { user_identity_provider, signer_identity, user_identity, - signer_state_path: paths.signer_state_path.clone(), - audit_dir: paths.audit_dir.clone(), - audit_config, + signer_store, + operation_audit_store, connection_approval_requirement: policy.default_approval_requirement(), policy, }) } - fn load_signer_manager_from_path(path: &Path) -> Result<RadrootsNostrSignerManager, MycError> { - Ok(RadrootsNostrSignerManager::new(Arc::new( - RadrootsNostrFileSignerStore::new(path), - ))?) + fn build_signer_store( + persistence: &MycPersistenceConfig, + path: &Path, + ) -> Arc<dyn RadrootsNostrSignerStore> { + match persistence.signer_state_backend { + MycSignerStateBackend::JsonFile => Arc::new(RadrootsNostrFileSignerStore::new(path)), + } + } + + fn build_operation_audit_store( + persistence: &MycPersistenceConfig, + audit_dir: &Path, + audit_config: MycAuditConfig, + ) -> Arc<dyn MycOperationAuditStore> { + match persistence.runtime_audit_backend { + MycRuntimeAuditBackend::JsonlFile => { + Arc::new(MycJsonlOperationAuditStore::new(audit_dir, audit_config)) + } + } + } + + fn load_signer_manager_from_store( + store: Arc<dyn RadrootsNostrSignerStore>, + ) -> Result<RadrootsNostrSignerManager, MycError> { + Ok(RadrootsNostrSignerManager::new(store)?) } } diff --git a/src/audit.rs b/src/audit.rs @@ -78,8 +78,44 @@ pub struct MycOperationAuditRecord { pub relay_outcome_summary: String, } +pub trait MycOperationAuditStore: Send + Sync { + fn config(&self) -> &MycAuditConfig; + fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError>; + fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_with_limit(self.config().default_read_limit) + } + fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError>; + fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError>; + fn list_for_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_for_connection_with_limit(connection_id, self.config().default_read_limit) + } + fn list_for_connection_with_limit( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + limit: usize, + ) -> Result<Vec<MycOperationAuditRecord>, MycError>; + fn list_for_attempt_id( + &self, + attempt_id: &str, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_for_attempt_id_with_limit(attempt_id, usize::MAX) + } + fn list_for_attempt_id_with_limit( + &self, + attempt_id: &str, + limit: usize, + ) -> Result<Vec<MycOperationAuditRecord>, MycError>; + fn latest_attempt_id_for_operation( + &self, + operation: MycOperationAuditKind, + ) -> Result<Option<String>, MycError>; +} + #[derive(Debug, Clone)] -pub struct MycOperationAuditStore { +pub struct MycJsonlOperationAuditStore { audit_dir: PathBuf, config: MycAuditConfig, } @@ -157,7 +193,7 @@ impl MycOperationAuditRecord { } } -impl MycOperationAuditStore { +impl MycJsonlOperationAuditStore { pub fn new(audit_dir: impl AsRef<Path>, config: MycAuditConfig) -> Self { Self { audit_dir: audit_dir.as_ref().to_path_buf(), @@ -683,6 +719,47 @@ impl MycOperationAuditStore { } } +impl MycOperationAuditStore for MycJsonlOperationAuditStore { + fn config(&self) -> &MycAuditConfig { + &self.config + } + + fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { + MycJsonlOperationAuditStore::append(self, record) + } + + fn list_all(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { + MycJsonlOperationAuditStore::list_all(self) + } + + fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { + MycJsonlOperationAuditStore::list_with_limit(self, limit) + } + + fn list_for_connection_with_limit( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + limit: usize, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + MycJsonlOperationAuditStore::list_for_connection_with_limit(self, connection_id, limit) + } + + fn list_for_attempt_id_with_limit( + &self, + attempt_id: &str, + limit: usize, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + MycJsonlOperationAuditStore::list_for_attempt_id_with_limit(self, attempt_id, limit) + } + + fn latest_attempt_id_for_operation( + &self, + operation: MycOperationAuditKind, + ) -> Result<Option<String>, MycError> { + MycJsonlOperationAuditStore::latest_attempt_id_for_operation(self, operation) + } +} + fn parse_archive_index(file_name: &str) -> Option<usize> { file_name .strip_prefix(MYC_OPERATION_AUDIT_ARCHIVE_PREFIX)? @@ -742,8 +819,8 @@ mod tests { use crate::config::MycAuditConfig; use super::{ - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, - MycOperationAuditStore, + MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, + MycOperationAuditRecord, }; fn config() -> MycAuditConfig { @@ -757,7 +834,7 @@ mod tests { #[test] fn append_and_list_operation_audit_records() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path(), config()); + let store = MycJsonlOperationAuditStore::new(temp.path(), config()); let connection_id = RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id"); @@ -809,7 +886,7 @@ mod tests { #[test] fn list_returns_empty_when_audit_file_is_missing() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path(), config()); + let store = MycJsonlOperationAuditStore::new(temp.path(), config()); assert!(store.list().expect("list missing records").is_empty()); } @@ -817,7 +894,7 @@ mod tests { #[test] fn rotation_and_bounded_reads_keep_recent_records() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new( + let store = MycJsonlOperationAuditStore::new( temp.path(), MycAuditConfig { default_read_limit: 3, @@ -855,7 +932,7 @@ mod tests { #[test] fn list_for_attempt_and_latest_attempt_id_work() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path(), config()); + let store = MycJsonlOperationAuditStore::new(temp.path(), config()); store .append( @@ -938,7 +1015,7 @@ mod tests { #[test] fn attempt_lookup_rebuilds_indexes_from_retained_logs() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path(), config()); + let store = MycJsonlOperationAuditStore::new(temp.path(), config()); store .append( diff --git a/src/config.rs b/src/config.rs @@ -21,6 +21,7 @@ pub struct MycConfig { pub service: MycServiceConfig, pub logging: MycLoggingConfig, pub paths: MycPathsConfig, + pub persistence: MycPersistenceConfig, pub audit: MycAuditConfig, pub observability: MycObservabilityConfig, pub discovery: MycDiscoveryConfig, @@ -60,6 +61,13 @@ pub struct MycPathsConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] +pub struct MycPersistenceConfig { + pub signer_state_backend: MycSignerStateBackend, + pub runtime_audit_backend: MycRuntimeAuditBackend, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct MycAuditConfig { pub default_read_limit: usize, pub max_active_file_bytes: u64, @@ -129,6 +137,18 @@ pub enum MycIdentityBackend { OsKeyring, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycSignerStateBackend { + JsonFile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycRuntimeAuditBackend { + JsonlFile, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycIdentitySourceSpec { pub backend: MycIdentityBackend, @@ -170,6 +190,7 @@ impl Default for MycConfig { service: MycServiceConfig::default(), logging: MycLoggingConfig::default(), paths: MycPathsConfig::default(), + persistence: MycPersistenceConfig::default(), audit: MycAuditConfig::default(), observability: MycObservabilityConfig::default(), discovery: MycDiscoveryConfig::default(), @@ -230,6 +251,15 @@ impl Default for MycTransportConfig { } } +impl Default for MycPersistenceConfig { + fn default() -> Self { + Self { + signer_state_backend: MycSignerStateBackend::JsonFile, + runtime_audit_backend: MycRuntimeAuditBackend::JsonlFile, + } + } +} + impl Default for MycAuditConfig { fn default() -> Self { Self { @@ -333,6 +363,22 @@ impl MycIdentityBackend { } } +impl MycSignerStateBackend { + pub fn as_str(self) -> &'static str { + match self { + Self::JsonFile => "json_file", + } + } +} + +impl MycRuntimeAuditBackend { + pub fn as_str(self) -> &'static str { + match self { + Self::JsonlFile => "jsonl_file", + } + } +} + impl MycPathsConfig { pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { MycIdentitySourceSpec { @@ -482,6 +528,16 @@ impl MycConfig { ); push_env_line( &mut lines, + "MYC_PERSISTENCE_SIGNER_STATE_BACKEND", + self.persistence.signer_state_backend.as_str(), + ); + push_env_line( + &mut lines, + "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND", + self.persistence.runtime_audit_backend.as_str(), + ); + push_env_line( + &mut lines, "MYC_AUDIT_DEFAULT_READ_LIMIT", self.audit.default_read_limit.to_string(), ); @@ -1019,6 +1075,14 @@ fn apply_env_entry( "MYC_PATHS_USER_IDENTITY_PROFILE_PATH" => { config.paths.user_identity_profile_path = parse_optional_path_env(value); } + "MYC_PERSISTENCE_SIGNER_STATE_BACKEND" => { + config.persistence.signer_state_backend = + parse_signer_state_backend_env(key, value, path, line_number)?; + } + "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND" => { + config.persistence.runtime_audit_backend = + parse_runtime_audit_backend_env(key, value, path, line_number)?; + } "MYC_AUDIT_DEFAULT_READ_LIMIT" => { config.audit.default_read_limit = parse_usize_env(key, value, path, line_number)?; } @@ -1278,6 +1342,38 @@ fn parse_delivery_policy_env( } } +fn parse_signer_state_backend_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<MycSignerStateBackend, MycError> { + match value { + "json_file" => Ok(MycSignerStateBackend::JsonFile), + _ => Err(config_parse_error( + path, + line_number, + format!("{key} must be `json_file`"), + )), + } +} + +fn parse_runtime_audit_backend_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<MycRuntimeAuditBackend, MycError> { + match value { + "jsonl_file" => Ok(MycRuntimeAuditBackend::JsonlFile), + _ => Err(config_parse_error( + path, + line_number, + format!("{key} must be `jsonl_file`"), + )), + } +} + fn parse_optional_string_env(value: &str) -> Option<String> { let value = value.trim(); if value.is_empty() { @@ -1646,6 +1742,14 @@ mod tests { ); assert_eq!(config.paths.user_identity_profile_path, None); assert_eq!( + config.persistence.signer_state_backend, + MycSignerStateBackend::JsonFile + ); + assert_eq!( + config.persistence.runtime_audit_backend, + MycRuntimeAuditBackend::JsonlFile + ); + assert_eq!( config.policy.connection_approval, MycConnectionApproval::ExplicitUser ); @@ -1701,6 +1805,8 @@ MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/myc-identity.json MYC_PATHS_USER_IDENTITY_BACKEND=filesystem MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json +MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file +MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file MYC_AUDIT_DEFAULT_READ_LIMIT=50 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 MYC_AUDIT_MAX_ARCHIVED_FILES=3 @@ -1765,6 +1871,14 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 config.paths.user_identity_path, PathBuf::from("/tmp/myc-user.json") ); + assert_eq!( + config.persistence.signer_state_backend, + MycSignerStateBackend::JsonFile + ); + assert_eq!( + config.persistence.runtime_audit_backend, + MycRuntimeAuditBackend::JsonlFile + ); assert_eq!(config.audit.default_read_limit, 50); assert_eq!(config.audit.max_active_file_bytes, 4096); assert_eq!(config.audit.max_archived_files, 3); @@ -2090,6 +2204,14 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery config.policy.connection_approval, MycConnectionApproval::ExplicitUser ); + assert_eq!( + config.persistence.signer_state_backend, + MycSignerStateBackend::JsonFile + ); + assert_eq!( + config.persistence.runtime_audit_backend, + MycRuntimeAuditBackend::JsonlFile + ); assert_eq!(config.policy.auth_pending_ttl_secs, 900); assert_eq!(config.transport.delivery_quorum, None); assert_eq!(config.transport.publish_max_attempts, 1); @@ -2118,6 +2240,8 @@ MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=/tmp/signer-profile.json MYC_PATHS_USER_IDENTITY_BACKEND=filesystem MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user +MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file +MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file MYC_AUDIT_DEFAULT_READ_LIMIT=50 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 MYC_AUDIT_MAX_ARCHIVED_FILES=3 diff --git a/src/lib.rs b/src/lib.rs @@ -15,13 +15,14 @@ pub mod transport; pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; pub use audit::{ - MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, - MycOperationAuditStore, + MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, + MycOperationAuditRecord, MycOperationAuditStore, }; pub use config::{ DEFAULT_ENV_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, - MycObservabilityConfig, MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig, + MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig, + MycRuntimeAuditBackend, MycServiceConfig, MycSignerStateBackend, MycTransportConfig, MycTransportDeliveryPolicy, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};