myc

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

commit 9c2a27ec147ebcd5a37bd53411e5893257788c9c
parent c1b9477907e83decd889ddf59b3dd0c3fa003faa
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 22:29:07 +0000

service: add runtime-backed signer backend

- add a myc signer backend adapter over the shared rr-rs signer contract
- route signer-facing cli and control flows through the runtime-backed backend
- expose signer backend capabilities and workflow counts in operability status
- cover the backend-first surface with tests and update the runtime docs

Diffstat:
Asrc/app/backend.rs | 426+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/mod.rs | 2++
Msrc/app/runtime.rs | 5+++++
Msrc/cli.rs | 25++++++++++---------------
Msrc/control.rs | 89++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/lib.rs | 11+++++++----
Msrc/operability/mod.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
7 files changed, 588 insertions(+), 60 deletions(-)

diff --git a/src/app/backend.rs b/src/app/backend.rs @@ -0,0 +1,426 @@ +use nostr::{PublicKey, RelayUrl, UnsignedEvent}; +use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, +}; +use radroots_nostr_signer::prelude::{ + RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, + RadrootsNostrRemoteSessionSignerCapability, RadrootsNostrSignerAuthorizationOutcome, + RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities, + RadrootsNostrSignerCapability, RadrootsNostrSignerConnectEvaluation, + RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, + RadrootsNostrSignerError, RadrootsNostrSignerManager, RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord, + RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, + RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerSessionLookup, + RadrootsNostrSignerSignOutput, RadrootsNostrSignerWorkflowId, +}; + +use crate::app::MycSignerContext; +use crate::error::MycError; + +#[derive(Clone)] +pub struct MycSignerBackend { + signer: MycSignerContext, +} + +impl MycSignerBackend { + pub fn new(signer: MycSignerContext) -> Self { + Self { signer } + } + + fn manager(&self) -> Result<RadrootsNostrSignerManager, RadrootsNostrSignerError> { + self.signer + .load_signer_manager() + .map_err(convert_runtime_signer_error) + } + + fn configured_signer_identity(&self) -> RadrootsIdentityPublic { + self.signer.signer_public_identity() + } + + fn local_signer_capability(&self) -> RadrootsNostrLocalSignerCapability { + let public_identity = self.configured_signer_identity(); + RadrootsNostrLocalSignerCapability::new( + public_identity.id.clone(), + public_identity, + RadrootsNostrLocalSignerAvailability::SecretBacked, + ) + } +} + +impl RadrootsNostrSignerBackend for MycSignerBackend { + fn signer_identity(&self) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { + Ok(Some(self.configured_signer_identity())) + } + + fn set_signer_identity( + &self, + signer_identity: RadrootsIdentityPublic, + ) -> Result<(), RadrootsNostrSignerError> { + let configured = self.configured_signer_identity(); + if configured.id != signer_identity.id + || configured.public_key_hex != signer_identity.public_key_hex + || configured.public_key_npub != signer_identity.public_key_npub + { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "runtime-backed myc signer backend cannot switch signer identity from `{}` to `{}`", + configured.id, signer_identity.id + ))); + } + self.manager()?.set_signer_identity(signer_identity) + } + + fn capabilities( + &self, + ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> { + let remote_sessions = self + .manager()? + .list_connections()? + .into_iter() + .filter(|record| record.status == RadrootsNostrSignerConnectionStatus::Active) + .map(|record| RadrootsNostrRemoteSessionSignerCapability::from(&record)) + .collect(); + Ok(RadrootsNostrSignerBackendCapabilities::new( + Some(self.local_signer_capability()), + remote_sessions, + )) + } + + fn list_connections( + &self, + ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + self.manager()?.list_connections() + } + + fn get_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + self.manager()?.get_connection(connection_id) + } + + fn list_publish_workflows( + &self, + ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { + self.manager()?.list_publish_workflows() + } + + fn get_publish_workflow( + &self, + workflow_id: &RadrootsNostrSignerWorkflowId, + ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError> { + self.manager()?.get_publish_workflow(workflow_id) + } + + fn find_connections_by_client_public_key( + &self, + client_public_key: &PublicKey, + ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + self.manager()? + .find_connections_by_client_public_key(client_public_key) + } + + fn find_connection_by_connect_secret( + &self, + connect_secret: &str, + ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + self.manager()? + .find_connection_by_connect_secret(connect_secret) + } + + fn lookup_session( + &self, + client_public_key: &PublicKey, + connect_secret: Option<&str>, + ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { + self.manager()? + .lookup_session(client_public_key, connect_secret) + } + + fn evaluate_connect_request( + &self, + client_public_key: PublicKey, + request: RadrootsNostrConnectRequest, + ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { + self.manager()? + .evaluate_connect_request(client_public_key, request) + } + + fn register_connection( + &self, + draft: RadrootsNostrSignerConnectionDraft, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.register_connection(draft) + } + + fn set_granted_permissions( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + granted_permissions: RadrootsNostrConnectPermissions, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()? + .set_granted_permissions(connection_id, granted_permissions) + } + + fn approve_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + granted_permissions: RadrootsNostrConnectPermissions, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()? + .approve_connection(connection_id, granted_permissions) + } + + fn reject_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + reason: Option<String>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.reject_connection(connection_id, reason) + } + + fn revoke_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + reason: Option<String>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.revoke_connection(connection_id, reason) + } + + fn update_relays( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + relays: Vec<RelayUrl>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.update_relays(connection_id, relays) + } + + fn require_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + auth_url: &str, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()? + .require_auth_challenge(connection_id, auth_url) + } + + fn set_pending_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()? + .set_pending_request(connection_id, request_message) + } + + fn authorize_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> { + self.manager()?.authorize_auth_challenge(connection_id) + } + + fn restore_pending_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + pending_request: RadrootsNostrSignerPendingRequest, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()? + .restore_pending_auth_challenge(connection_id, pending_request) + } + + fn begin_connect_secret_publish_finalization( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { + self.manager()? + .begin_connect_secret_publish_finalization(connection_id) + .map(RadrootsNostrSignerPublishTransition::begun) + } + + fn begin_auth_replay_publish_finalization( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { + self.manager()? + .begin_auth_replay_publish_finalization(connection_id) + .map(RadrootsNostrSignerPublishTransition::begun) + } + + fn mark_publish_workflow_published( + &self, + workflow_id: &RadrootsNostrSignerWorkflowId, + ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { + self.manager()? + .mark_publish_workflow_published(workflow_id) + .map(RadrootsNostrSignerPublishTransition::marked_published) + } + + fn finalize_publish_workflow( + &self, + workflow_id: &RadrootsNostrSignerWorkflowId, + ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { + let connection = self.manager()?.finalize_publish_workflow(workflow_id)?; + Ok(RadrootsNostrSignerPublishTransition::finalized( + workflow_id.clone(), + connection, + )) + } + + fn cancel_publish_workflow( + &self, + workflow_id: &RadrootsNostrSignerWorkflowId, + ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> { + self.manager()? + .cancel_publish_workflow(workflow_id) + .map(RadrootsNostrSignerPublishTransition::cancelled) + } + + fn mark_authenticated( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.mark_authenticated(connection_id) + } + + fn mark_connect_secret_consumed( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.manager()?.mark_connect_secret_consumed(connection_id) + } + + fn evaluate_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { + self.manager()? + .evaluate_request(connection_id, request_message) + } + + fn evaluate_auth_replay_publish_workflow( + &self, + workflow_id: &RadrootsNostrSignerWorkflowId, + ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { + self.manager()? + .evaluate_auth_replay_publish_workflow(workflow_id) + } + + fn record_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_id: &str, + method: RadrootsNostrConnectMethod, + decision: RadrootsNostrSignerRequestDecision, + message: Option<String>, + ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { + self.manager()? + .record_request(connection_id, request_id, method, decision, message) + } + + fn sign_unsigned_event( + &self, + unsigned_event: UnsignedEvent, + ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { + let event = self + .signer + .signer_identity() + .sign_unsigned_event(unsigned_event, "myc signer backend event") + .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string()))?; + Ok(RadrootsNostrSignerSignOutput::new( + RadrootsNostrSignerCapability::LocalAccount(self.local_signer_capability()), + event, + )) + } +} + +fn convert_runtime_signer_error(error: MycError) -> RadrootsNostrSignerError { + match error { + MycError::SignerState(source) => source, + other => RadrootsNostrSignerError::InvalidState(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use nostr::Keys; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft, + }; + + use crate::app::MycRuntime; + use crate::config::MycConfig; + + fn write_identity(path: &std::path::Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn test_runtime() -> MycRuntime { + let temp = tempfile::tempdir().expect("tempdir").keep(); + let mut config = MycConfig::default(); + config.paths.state_dir = PathBuf::from(&temp).join("state"); + config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); + config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + MycRuntime::bootstrap(config).expect("runtime") + } + + #[test] + fn runtime_backed_backend_projects_local_and_remote_capabilities() { + let runtime = test_runtime(); + let backend = runtime.signer_backend(); + + let initial = backend.capabilities().expect("capabilities"); + assert!( + initial + .local_signer + .expect("local signer capability") + .is_secret_backed() + ); + assert!(initial.remote_sessions.is_empty()); + + let connection = backend + .register_connection(RadrootsNostrSignerConnectionDraft::new( + Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let capabilities = backend.capabilities().expect("capabilities after approval"); + assert_eq!(capabilities.remote_sessions.len(), 1); + assert_eq!( + capabilities.remote_sessions[0].connection_id, + connection.connection_id + ); + } + + #[test] + fn runtime_backed_backend_rejects_signer_identity_drift() { + let runtime = test_runtime(); + let backend = runtime.signer_backend(); + let other_identity = RadrootsIdentity::generate().to_public(); + + let error = backend + .set_signer_identity(other_identity) + .expect_err("identity drift should be rejected"); + + assert!(error.to_string().contains("cannot switch signer identity")); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -1,8 +1,10 @@ +pub mod backend; pub mod runtime; use crate::config::MycConfig; use crate::error::MycError; +pub use backend::MycSignerBackend; pub use runtime::{MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; #[derive(Clone)] diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -4,6 +4,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; +use super::backend::MycSignerBackend; use crate::audit::{ MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycOperationAuditStore, @@ -162,6 +163,10 @@ impl MycRuntime { self.signer.load_signer_manager() } + pub fn signer_backend(&self) -> MycSignerBackend { + MycSignerBackend::new(self.signer.clone()) + } + pub fn transport(&self) -> Option<&MycNostrTransport> { self.transport.as_ref() } diff --git a/src/cli.rs b/src/cli.rs @@ -4,8 +4,8 @@ use std::path::{Path, PathBuf}; use clap::{Args, Parser, Subcommand, ValueEnum}; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerRequestAuditRecord, + RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionId, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord, }; use serde::Serialize; @@ -432,34 +432,29 @@ pub async fn run_from_env() -> Result<(), MycError> { } MycCommand::Connections { command } => { let runtime = MycRuntime::bootstrap(config)?; + let backend = runtime.signer_backend(); match command { - MycConnectionsCommand::List => { - let manager = runtime.signer_manager()?; - print_json(&manager.list_connections()?) - } + MycConnectionsCommand::List => print_json(&backend.list_connections()?), MycConnectionsCommand::Approve(args) => { let connection_id = parse_connection_id(&args.connection_id)?; - let manager = runtime.signer_manager()?; let granted_permissions = granted_permissions_for_approval( runtime.signer_context().policy(), - &manager.list_connections()?, + &backend.list_connections()?, &connection_id, &args.grants, )?; let connection = - manager.approve_connection(&connection_id, granted_permissions)?; + backend.approve_connection(&connection_id, granted_permissions)?; print_json(&connection) } MycConnectionsCommand::Reject(args) => { let connection_id = parse_connection_id(&args.connection_id)?; - let manager = runtime.signer_manager()?; - let connection = manager.reject_connection(&connection_id, args.reason)?; + let connection = backend.reject_connection(&connection_id, args.reason)?; print_json(&connection) } MycConnectionsCommand::Revoke(args) => { let connection_id = parse_connection_id(&args.connection_id)?; - let manager = runtime.signer_manager()?; - let connection = manager.revoke_connection(&connection_id, args.reason)?; + let connection = backend.revoke_connection(&connection_id, args.reason)?; print_json(&connection) } } @@ -513,11 +508,11 @@ pub async fn run_from_env() -> Result<(), MycError> { } MycCommand::Auth { command } => { let runtime = MycRuntime::bootstrap(config)?; + let backend = runtime.signer_backend(); match command { MycAuthCommand::Require { connection_id, url } => { let connection_id = parse_connection_id(&connection_id)?; - let manager = runtime.signer_manager()?; - let connection = manager.require_auth_challenge(&connection_id, url)?; + let connection = backend.require_auth_challenge(&connection_id, &url)?; print_json(&connection) } MycAuthCommand::Authorize { connection_id } => { diff --git a/src/control.rs b/src/control.rs @@ -5,9 +5,10 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectResponse, RadrootsNostrConnectUri, }; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionId, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestId, - RadrootsNostrSignerWorkflowId, + RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend, + RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord, + RadrootsNostrSignerRequestId, RadrootsNostrSignerWorkflowId, }; use serde::Serialize; @@ -34,20 +35,23 @@ pub async fn authorize_auth_challenge( runtime: &MycRuntime, connection_id: &RadrootsNostrSignerConnectionId, ) -> Result<MycAuthorizedReplayOutput, MycError> { - let manager = runtime.signer_manager()?; - let connection = manager.get_connection(connection_id)?.ok_or_else(|| { + let backend = runtime.signer_backend(); + let connection = backend.get_connection(connection_id)?.ok_or_else(|| { MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) })?; runtime .signer_context() .policy() .ensure_authorize_auth_challenge_allowed(&connection)?; - let workflow = manager.begin_auth_replay_publish_finalization(connection_id)?; + let workflow = workflow_from_transition( + backend.begin_auth_replay_publish_finalization(connection_id)?, + "auth replay", + )?; let replayed_request_id = replay_authorized_request(runtime, &connection.connection_id, &workflow.workflow_id) .await?; let connection = runtime - .signer_manager()? + .signer_backend() .get_connection(connection_id)? .ok_or_else(|| { MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) @@ -88,7 +92,7 @@ pub async fn accept_client_uri( secret: Some(client_uri.secret.clone()), requested_permissions: client_uri.metadata.requested_permissions.clone(), }; - let manager = runtime.signer_manager()?; + let backend = runtime.signer_backend(); let Some(approval_requirement) = runtime .signer_context() .policy() @@ -98,7 +102,7 @@ pub async fn accept_client_uri( "client public key denied by policy".to_owned(), )); }; - let connection = match manager.evaluate_connect_request(client_uri.client_public_key, request)? { + let connection = match backend.evaluate_connect_request(client_uri.client_public_key, request)? { radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection( connection, ) => { @@ -132,7 +136,7 @@ pub async fn accept_client_uri( .with_requested_permissions(requested_permissions) .with_relays(preferred_relays.clone()) .with_approval_requirement(approval_requirement); - let connection = manager.register_connection(draft)?; + let connection = backend.register_connection(draft)?; if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired { @@ -140,7 +144,7 @@ pub async fn accept_client_uri( .signer_context() .policy() .auto_granted_permissions(&connection.requested_permissions); - let _ = manager.set_granted_permissions( + let _ = backend.set_granted_permissions( &connection.connection_id, granted_permissions, )?; @@ -157,7 +161,10 @@ pub async fn accept_client_uri( RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret), )?; let response_relays = merge_relays(&client_uri.relays, &preferred_relays); - let workflow = manager.begin_connect_secret_publish_finalization(&connection.connection_id)?; + let workflow = workflow_from_transition( + backend.begin_connect_secret_publish_finalization(&connection.connection_id)?, + "connect accept", + )?; let event = match runtime .signer_identity() .sign_event_builder(event, "connect accept response") @@ -223,7 +230,7 @@ pub async fn accept_client_uri( )); } }; - if let Err(error) = manager.mark_publish_workflow_published(&workflow.workflow_id) { + if let Err(error) = backend.mark_publish_workflow_published(&workflow.workflow_id) { record_post_publish_failure( runtime, MycOperationAuditKind::ConnectAcceptPublish, @@ -248,7 +255,7 @@ pub async fn accept_client_uri( ); return Err(error); } - if let Err(error) = manager.finalize_publish_workflow(&workflow.workflow_id) { + if let Err(error) = backend.finalize_publish_workflow(&workflow.workflow_id) { record_post_publish_failure( runtime, MycOperationAuditKind::ConnectAcceptPublish, @@ -283,11 +290,8 @@ pub async fn accept_client_uri( ); Ok(MycAcceptedConnectionOutput { - connection: runtime - .signer_manager()? - .list_connections()? - .into_iter() - .find(|record| record.connection_id == connection.connection_id) + connection: backend + .get_connection(&connection.connection_id)? .ok_or_else(|| { MycError::InvalidOperation("accepted connection was not persisted".to_owned()) })?, @@ -319,8 +323,8 @@ async fn replay_authorized_request( connection_id: &RadrootsNostrSignerConnectionId, workflow_id: &RadrootsNostrSignerWorkflowId, ) -> Result<Option<String>, MycError> { - let manager = runtime.signer_manager()?; - let workflow = manager.get_publish_workflow(workflow_id)?.ok_or_else(|| { + let backend = runtime.signer_backend(); + let workflow = backend.get_publish_workflow(workflow_id)?.ok_or_else(|| { MycError::InvalidOperation(format!("publish workflow `{workflow_id}` was not found")) })?; let Some(pending_request) = workflow.pending_request.clone() else { @@ -342,7 +346,7 @@ async fn replay_authorized_request( } }; let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec()); - let evaluation = match manager.evaluate_auth_replay_publish_workflow(workflow_id) { + let evaluation = match backend.evaluate_auth_replay_publish_workflow(workflow_id) { Ok(evaluation) => evaluation, Err(error) => { return Err(cancel_auth_replay_workflow_on_error( @@ -396,7 +400,7 @@ async fn replay_authorized_request( )); } let event = match handler.build_response_event( - manager + backend .get_connection(connection_id)? .ok_or_else(|| { MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) @@ -416,7 +420,7 @@ async fn replay_authorized_request( )); } }; - let connection = manager.get_connection(connection_id)?.ok_or_else(|| { + let connection = backend.get_connection(connection_id)?.ok_or_else(|| { MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) })?; let event = match runtime @@ -497,7 +501,7 @@ async fn replay_authorized_request( )); } }; - if let Err(error) = manager.mark_publish_workflow_published(workflow_id) { + if let Err(error) = backend.mark_publish_workflow_published(workflow_id) { record_post_publish_failure( runtime, MycOperationAuditKind::AuthReplayPublish, @@ -522,7 +526,7 @@ async fn replay_authorized_request( ); return Err(error); } - if let Err(error) = manager.finalize_publish_workflow(workflow_id) { + if let Err(error) = backend.finalize_publish_workflow(workflow_id) { record_post_publish_failure( runtime, MycOperationAuditKind::AuthReplayPublish, @@ -566,11 +570,11 @@ fn cancel_auth_replay_workflow_on_error( error: MycError, ) -> MycError { let summary = publish_failure_summary(&error); - match runtime.signer_manager().and_then(|manager| { - manager - .cancel_publish_workflow(workflow_id) - .map_err(Into::into) - }) { + match runtime + .signer_backend() + .cancel_publish_workflow(workflow_id) + .map_err(MycError::from) + { Ok(_) => { let mut record = MycOperationAuditRecord::new( MycOperationAuditKind::AuthReplayRestore, @@ -616,12 +620,12 @@ fn cancel_connect_accept_workflow_on_error( workflow_id: &RadrootsNostrSignerWorkflowId, error: MycError, ) -> MycError { - match runtime.signer_manager().and_then(|manager| { - manager - .cancel_publish_workflow(workflow_id) - .map(|_| ()) - .map_err(Into::into) - }) { + match runtime + .signer_backend() + .cancel_publish_workflow(workflow_id) + .map(|_| ()) + .map_err(MycError::from) + { Ok(()) => error, Err(cancel_error) => MycError::InvalidOperation(format!( "{error}; additionally failed to cancel connect-accept publish workflow: {cancel_error}" @@ -629,6 +633,17 @@ fn cancel_connect_accept_workflow_on_error( } } +fn workflow_from_transition( + transition: RadrootsNostrSignerPublishTransition, + operation: &str, +) -> Result<RadrootsNostrSignerPublishWorkflowRecord, MycError> { + transition.workflow().cloned().ok_or_else(|| { + MycError::InvalidOperation(format!( + "{operation} publish workflow did not return a workflow record" + )) + }) +} + fn build_control_outbox_record( kind: MycDeliveryOutboxKind, event: radroots_nostr::prelude::RadrootsNostrEvent, diff --git a/src/lib.rs b/src/lib.rs @@ -17,7 +17,9 @@ pub mod persistence; pub mod policy; pub mod transport; -pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; +pub use app::{ + MycApp, MycRuntime, MycRuntimePaths, MycSignerBackend, MycSignerContext, MycStartupSnapshot, +}; pub use audit::{ MycJsonlOperationAuditStore, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycOperationAuditStore, @@ -52,9 +54,10 @@ pub use operability::{ MycDeliveryRecoveryStatusOutput, MycDiscoveryStatusOutput, MycMetricsSnapshot, MycOperationOutcomeCounts, MycPersistenceStatusOutput, MycRelayProbe, MycRelayProbeAvailability, MycRuntimeAuditPersistenceStatusOutput, MycRuntimeStatus, - MycSignerStatePersistenceStatusOutput, MycSqliteSchemaStatusOutput, MycStatusFullOutput, - MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, - collect_status_summary, render_metrics_text, + MycSignerBackendStatusOutput, MycSignerStatePersistenceStatusOutput, + MycSqliteSchemaStatusOutput, MycStatusFullOutput, MycStatusSummaryOutput, + MycTransportStatusOutput, collect_metrics, collect_status_full, collect_status_summary, + render_metrics_text, }; pub use outbox::{ MycDeliveryOutboxJobId, MycDeliveryOutboxKind, MycDeliveryOutboxRecord, diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -7,8 +7,10 @@ use std::time::Duration; use radroots_nostr::prelude::{RadrootsNostrRelayStatus, RadrootsNostrRelayUrl}; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, + RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability, + RadrootsNostrSignerBackend, RadrootsNostrSignerPublishWorkflowRecord, + RadrootsNostrSignerPublishWorkflowState, RadrootsNostrSignerRequestAuditRecord, + RadrootsNostrSignerRequestDecision, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde::{Deserialize, Serialize}; @@ -103,6 +105,16 @@ pub struct MycPersistenceStatusOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycSignerBackendStatusOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub local_signer: Option<RadrootsNostrLocalSignerCapability>, + pub remote_session_count: usize, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, + pub publish_workflow_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycDeliveryRecoveryStatusOutput { pub recorded_at_unix: u64, pub outcome: MycOperationAuditOutcome, @@ -172,6 +184,7 @@ pub struct MycStatusFullOutput { pub ready: bool, pub reasons: Vec<String>, pub startup: crate::app::MycStartupSnapshot, + pub signer_backend: MycSignerBackendStatusOutput, pub custody: MycCustodyStatusOutput, pub persistence: MycPersistenceStatusOutput, pub delivery_outbox: MycDeliveryOutboxStatusOutput, @@ -185,6 +198,7 @@ pub struct MycStatusSummaryOutput { pub ready: bool, pub reasons: Vec<String>, pub instance_name: String, + pub signer_backend: MycSignerBackendStatusOutput, pub custody: MycCustodyStatusOutput, pub persistence: MycPersistenceStatusOutput, pub delivery_outbox: MycDeliveryOutboxStatusOutput, @@ -301,6 +315,7 @@ struct MycSqliteStoreVersionRow { pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOutput, MycError> { let snapshot = runtime.snapshot(); + let signer_backend = collect_signer_backend_status(runtime)?; let custody = collect_custody_status(runtime)?; let persistence = collect_persistence_status(runtime); let delivery_outbox = collect_delivery_outbox_status(runtime)?; @@ -338,6 +353,7 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu ready, reasons, startup: snapshot, + signer_backend, custody: custody.output, persistence: persistence.output, delivery_outbox: delivery_outbox.output, @@ -355,6 +371,12 @@ pub async fn collect_status_summary( ready: full.ready, reasons: full.reasons, instance_name: full.startup.instance_name, + signer_backend: MycSignerBackendStatusOutput { + local_signer: full.signer_backend.local_signer.clone(), + remote_session_count: full.signer_backend.remote_session_count, + remote_sessions: Vec::new(), + publish_workflow_count: full.signer_backend.publish_workflow_count, + }, custody: full.custody, persistence: full.persistence, delivery_outbox: full.delivery_outbox, @@ -413,6 +435,20 @@ fn collect_custody_status(runtime: &MycRuntime) -> Result<MycCustodyStatusEvalua }) } +fn collect_signer_backend_status( + runtime: &MycRuntime, +) -> Result<MycSignerBackendStatusOutput, MycError> { + let backend = runtime.signer_backend(); + let capabilities = backend.capabilities()?; + let publish_workflow_count = backend.list_publish_workflows()?.len(); + Ok(MycSignerBackendStatusOutput { + local_signer: capabilities.local_signer, + remote_session_count: capabilities.remote_sessions.len(), + remote_sessions: capabilities.remote_sessions, + publish_workflow_count, + }) +} + fn collect_persistence_status(runtime: &MycRuntime) -> MycPersistenceStatusEvaluation { let signer_state_backend = runtime.config().persistence.signer_state_backend; let runtime_audit_backend = runtime.config().persistence.runtime_audit_backend; @@ -958,7 +994,7 @@ fn collect_delivery_outbox_status( ) -> Result<MycDeliveryOutboxStatusEvaluation, MycError> { let outbox_records = runtime.delivery_outbox_store().list_all()?; let workflow_by_id = runtime - .signer_manager()? + .signer_backend() .list_publish_workflows()? .into_iter() .map(|workflow| (workflow.workflow_id.to_string(), workflow)) @@ -1557,7 +1593,8 @@ mod tests { use super::{ MycMetricsSnapshot, MycOperationOutcomeCounts, MycRuntimeStatus, collect_metrics, - inspect_runtime_audit_sqlite_schema, render_metrics_text, worse_runtime_status, + collect_status_full, inspect_runtime_audit_sqlite_schema, render_metrics_text, + worse_runtime_status, }; use crate::app::{MycRuntime, MycRuntimePaths}; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; @@ -1716,4 +1753,49 @@ mod tests { assert_eq!(metrics.runtime_operation_outcomes.succeeded, 1); assert_eq!(metrics.delivery_recovery_success_count, 1); } + + #[tokio::test(flavor = "current_thread")] + async fn status_full_reports_signer_backend_capabilities() { + use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionDraft, + }; + + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config).expect("runtime"); + let backend = runtime.signer_backend(); + let connection = backend + .register_connection(RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let status = collect_status_full(&runtime).await.expect("status"); + assert!( + status + .signer_backend + .local_signer + .expect("local signer") + .is_secret_backed() + ); + assert_eq!(status.signer_backend.remote_session_count, 1); + assert_eq!(status.signer_backend.remote_sessions.len(), 1); + assert_eq!( + status.signer_backend.remote_sessions[0].connection_id, + connection.connection_id + ); + } }