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 | +++ |
| M | src/app/runtime.rs | | | 67 | +++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
| M | src/audit.rs | | | 95 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- |
| M | src/config.rs | | | 124 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/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};