commit 506e299aed7867d7fdebfbe5d73cf775cc50c865
parent 16f972d0b3e5443ae74775dbdf9fe899074b3646
Author: triesap <tyson@radroots.org>
Date: Fri, 27 Mar 2026 00:57:56 +0000
custody: add managed account lifecycle
Diffstat:
| M | .env.example | | | 7 | +++++++ |
| M | src/cli.rs | | | 173 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/config.rs | | | 125 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | src/custody.rs | | | 543 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/error.rs | | | 17 | +++++++++++++++++ |
| M | src/lib.rs | | | 5 | ++++- |
6 files changed, 851 insertions(+), 19 deletions(-)
diff --git a/.env.example b/.env.example
@@ -5,11 +5,16 @@ MYC_LOGGING_STDOUT=true
MYC_PATHS_STATE_DIR=/var/lib/myc
MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem
+# filesystem: identity file path
+# managed_account: account store file path
MYC_PATHS_SIGNER_IDENTITY_PATH=/etc/myc/identities/signer-identity.json
MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=
+# os_keyring and managed_account both require a non-empty keyring service name
MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.signer
MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=
MYC_PATHS_USER_IDENTITY_BACKEND=filesystem
+# filesystem: identity file path
+# managed_account: account store file path
MYC_PATHS_USER_IDENTITY_PATH=/etc/myc/identities/user-identity.json
MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID=
MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user
@@ -31,6 +36,8 @@ MYC_DISCOVERY_ENABLED=true
MYC_DISCOVERY_DOMAIN=myc.radroots.org
MYC_DISCOVERY_HANDLER_IDENTIFIER=myc
MYC_DISCOVERY_APP_IDENTITY_BACKEND=
+# filesystem: identity file path
+# managed_account: account store file path
MYC_DISCOVERY_APP_IDENTITY_PATH=/etc/myc/identities/app-identity.json
MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID=
MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.discovery
diff --git a/src/cli.rs b/src/cli.rs
@@ -51,6 +51,10 @@ pub enum MycCommand {
#[command(subcommand)]
command: MycPersistenceCommand,
},
+ Custody {
+ #[command(subcommand)]
+ command: MycCustodyCommand,
+ },
Connections {
#[command(subcommand)]
command: MycConnectionsCommand,
@@ -92,6 +96,44 @@ pub enum MycPersistenceCommand {
}
#[derive(Debug, Subcommand)]
+pub enum MycCustodyCommand {
+ List {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ },
+ Generate {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ label: Option<String>,
+ #[arg(long)]
+ select: bool,
+ },
+ ImportFile {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ path: PathBuf,
+ #[arg(long)]
+ label: Option<String>,
+ #[arg(long)]
+ select: bool,
+ },
+ Select {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ account_id: String,
+ },
+ Remove {
+ #[arg(long, value_enum)]
+ role: MycCustodyRole,
+ #[arg(long)]
+ account_id: String,
+ },
+}
+
+#[derive(Debug, Subcommand)]
pub enum MycAuditCommand {
List {
#[arg(long)]
@@ -150,6 +192,13 @@ pub enum MycMetricsFormat {
Prometheus,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum MycCustodyRole {
+ Signer,
+ User,
+ DiscoveryApp,
+}
+
#[derive(Debug, Subcommand)]
pub enum MycAuthCommand {
Require {
@@ -330,6 +379,33 @@ pub async fn run_from_env() -> Result<(), MycError> {
print_json(&output)
}
},
+ MycCommand::Custody { command } => {
+ let provider = custody_provider_for_command(&config, &command)?;
+ match command {
+ MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?),
+ MycCustodyCommand::Generate { label, select, .. } => {
+ let output = provider.generate_managed_account(label, select)?;
+ print_json(&output)
+ }
+ MycCustodyCommand::ImportFile {
+ path,
+ label,
+ select,
+ ..
+ } => {
+ let output = provider.import_managed_account_file(path, label, select)?;
+ print_json(&output)
+ }
+ MycCustodyCommand::Select { account_id, .. } => {
+ let output = provider.select_managed_account(account_id.as_str())?;
+ print_json(&output)
+ }
+ MycCustodyCommand::Remove { account_id, .. } => {
+ let output = provider.remove_managed_account(account_id.as_str())?;
+ print_json(&output)
+ }
+ }
+ }
MycCommand::Connections { command } => {
let runtime = MycRuntime::bootstrap(config)?;
match command {
@@ -505,6 +581,45 @@ fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> {
}
}
+fn custody_provider_for_command(
+ config: &MycConfig,
+ command: &MycCustodyCommand,
+) -> Result<crate::custody::MycIdentityProvider, MycError> {
+ let role = match command {
+ MycCustodyCommand::List { role }
+ | MycCustodyCommand::Generate { role, .. }
+ | MycCustodyCommand::ImportFile { role, .. }
+ | MycCustodyCommand::Select { role, .. }
+ | MycCustodyCommand::Remove { role, .. } => *role,
+ };
+
+ custody_provider_for_role(config, role)
+}
+
+fn custody_provider_for_role(
+ config: &MycConfig,
+ role: MycCustodyRole,
+) -> Result<crate::custody::MycIdentityProvider, MycError> {
+ match role {
+ MycCustodyRole::Signer => crate::custody::MycIdentityProvider::from_source(
+ "signer",
+ config.paths.signer_identity_source(),
+ ),
+ MycCustodyRole::User => crate::custody::MycIdentityProvider::from_source(
+ "user",
+ config.paths.user_identity_source(),
+ ),
+ MycCustodyRole::DiscoveryApp => {
+ let Some(source) = config.discovery.app_identity_source() else {
+ return Err(MycError::InvalidOperation(
+ "discovery app identity is not separately configured; it currently reuses the signer identity".to_owned(),
+ ));
+ };
+ crate::custody::MycIdentityProvider::from_source("discovery app", source)
+ }
+ }
+}
+
fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> {
Ok(RadrootsNostrSignerConnectionId::parse(value)?)
}
@@ -833,6 +948,7 @@ fn print_text(value: &str) {
mod tests {
use std::path::PathBuf;
+ use clap::Parser;
use nostr::Timestamp;
use radroots_identity::RadrootsIdentity;
use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest;
@@ -843,7 +959,8 @@ mod tests {
use crate::config::MycConfig;
use super::{
- MycAuditScope, granted_permissions_for_approval, load_audit_output, summarize_audit_output,
+ MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole,
+ granted_permissions_for_approval, load_audit_output, summarize_audit_output,
};
use crate::app::MycRuntime;
@@ -1166,4 +1283,58 @@ mod tests {
1
);
}
+
+ #[test]
+ fn parses_custody_list_command() {
+ let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"])
+ .expect("parse custody list");
+
+ assert!(matches!(
+ cli.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::List {
+ role: MycCustodyRole::Signer
+ }
+ })
+ ));
+ }
+
+ #[test]
+ fn parses_custody_generate_and_import_commands() {
+ let generate = MycCli::try_parse_from([
+ "myc", "custody", "generate", "--role", "user", "--label", "primary", "--select",
+ ])
+ .expect("parse custody generate");
+ assert!(matches!(
+ generate.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::Generate {
+ role: MycCustodyRole::User,
+ select: true,
+ ..
+ }
+ })
+ ));
+
+ let import = MycCli::try_parse_from([
+ "myc",
+ "custody",
+ "import-file",
+ "--role",
+ "discovery-app",
+ "--path",
+ "/tmp/discovery.json",
+ ])
+ .expect("parse custody import");
+ assert!(matches!(
+ import.command,
+ Some(MycCommand::Custody {
+ command: MycCustodyCommand::ImportFile {
+ role: MycCustodyRole::DiscoveryApp,
+ select: false,
+ ..
+ }
+ })
+ ));
+ }
}
diff --git a/src/config.rs b/src/config.rs
@@ -135,6 +135,7 @@ pub enum MycConnectionApproval {
pub enum MycIdentityBackend {
Filesystem,
OsKeyring,
+ ManagedAccount,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -361,6 +362,7 @@ impl MycIdentityBackend {
match self {
Self::Filesystem => "filesystem",
Self::OsKeyring => "os_keyring",
+ Self::ManagedAccount => "managed_account",
}
}
}
@@ -388,21 +390,23 @@ impl MycPathsConfig {
MycIdentitySourceSpec {
backend: self.signer_identity_backend,
path: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem => Some(self.signer_identity_path.clone()),
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
+ Some(self.signer_identity_path.clone())
+ }
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.signer_identity_keyring_account_id.clone(),
},
keyring_service_name: match self.signer_identity_backend {
MycIdentityBackend::Filesystem => None,
- MycIdentityBackend::OsKeyring => {
+ MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
Some(self.signer_identity_keyring_service_name.clone())
}
},
profile_path: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.signer_identity_profile_path.clone(),
},
}
@@ -412,21 +416,23 @@ impl MycPathsConfig {
MycIdentitySourceSpec {
backend: self.user_identity_backend,
path: match self.user_identity_backend {
- MycIdentityBackend::Filesystem => Some(self.user_identity_path.clone()),
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
+ Some(self.user_identity_path.clone())
+ }
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match self.user_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.user_identity_keyring_account_id.clone(),
},
keyring_service_name: match self.user_identity_backend {
MycIdentityBackend::Filesystem => None,
- MycIdentityBackend::OsKeyring => {
+ MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
Some(self.user_identity_keyring_service_name.clone())
}
},
profile_path: match self.user_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.user_identity_profile_path.clone(),
},
}
@@ -1308,10 +1314,11 @@ fn parse_identity_backend_env(
match value {
"filesystem" => Ok(MycIdentityBackend::Filesystem),
"os_keyring" => Ok(MycIdentityBackend::OsKeyring),
+ "managed_account" => Ok(MycIdentityBackend::ManagedAccount),
_ => Err(config_parse_error(
path,
line_number,
- format!("{key} must be `filesystem` or `os_keyring`"),
+ format!("{key} must be `filesystem`, `os_keyring`, or `managed_account`"),
)),
}
}
@@ -1505,6 +1512,38 @@ fn validate_identity_source_config(
)));
}
}
+ MycIdentityBackend::ManagedAccount => {
+ let Some(path) = source.path.as_ref() else {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.path must be set when backend is `managed_account`"
+ )));
+ };
+ if path.as_os_str().is_empty() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.path must not be empty when backend is `managed_account`"
+ )));
+ }
+ let Some(service_name) = source.keyring_service_name.as_deref() else {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.keyring_service_name must be set when backend is `managed_account`"
+ )));
+ };
+ if service_name.trim().is_empty() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.keyring_service_name must not be empty when backend is `managed_account`"
+ )));
+ }
+ if source.keyring_account_id.is_some() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.keyring_account_id must not be set when backend is `managed_account`"
+ )));
+ }
+ if source.profile_path.is_some() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.profile_path must not be set when backend is `managed_account`"
+ )));
+ }
+ }
}
Ok(())
@@ -1536,19 +1575,23 @@ impl MycDiscoveryConfig {
Some(MycIdentitySourceSpec {
backend,
path: match backend {
- MycIdentityBackend::Filesystem => self.app_identity_path.clone(),
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
+ self.app_identity_path.clone()
+ }
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.app_identity_keyring_account_id.clone(),
},
keyring_service_name: match backend {
MycIdentityBackend::Filesystem => None,
- MycIdentityBackend::OsKeyring => self.app_identity_keyring_service_name.clone(),
+ MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
+ self.app_identity_keyring_service_name.clone()
+ }
},
profile_path: match backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
MycIdentityBackend::OsKeyring => self.app_identity_profile_path.clone(),
},
})
@@ -2187,6 +2230,62 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
}
#[test]
+ fn parse_and_validate_managed_account_identity_backends() {
+ let config = MycConfig::from_env_str(
+ r#"
+MYC_PATHS_SIGNER_IDENTITY_BACKEND=managed_account
+MYC_PATHS_SIGNER_IDENTITY_PATH=/var/lib/myc/custody/signer-accounts.json
+MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
+MYC_PATHS_USER_IDENTITY_BACKEND=managed_account
+MYC_PATHS_USER_IDENTITY_PATH=/var/lib/myc/custody/user-accounts.json
+MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user
+MYC_DISCOVERY_ENABLED=true
+MYC_DISCOVERY_DOMAIN=myc.example.com
+MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.example.com
+MYC_DISCOVERY_APP_IDENTITY_BACKEND=managed_account
+MYC_DISCOVERY_APP_IDENTITY_PATH=/var/lib/myc/custody/discovery-accounts.json
+MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
+ "#,
+ )
+ .expect("config");
+
+ assert_eq!(
+ config.paths.signer_identity_backend,
+ MycIdentityBackend::ManagedAccount
+ );
+ assert_eq!(
+ config.paths.signer_identity_source().path,
+ Some(PathBuf::from("/var/lib/myc/custody/signer-accounts.json"))
+ );
+ assert_eq!(
+ config
+ .paths
+ .signer_identity_source()
+ .keyring_service_name
+ .as_deref(),
+ Some("org.radroots.myc.test.signer")
+ );
+ assert_eq!(
+ config.paths.user_identity_backend,
+ MycIdentityBackend::ManagedAccount
+ );
+ assert_eq!(
+ config.discovery.app_identity_backend,
+ Some(MycIdentityBackend::ManagedAccount)
+ );
+ assert_eq!(
+ config
+ .discovery
+ .app_identity_source()
+ .expect("app identity source")
+ .path,
+ Some(PathBuf::from(
+ "/var/lib/myc/custody/discovery-accounts.json"
+ ))
+ );
+ }
+
+ #[test]
fn example_env_parses_and_validates() {
let example =
fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example"))
diff --git a/src/custody.rs b/src/custody.rs
@@ -1,3 +1,4 @@
+use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
@@ -8,7 +9,9 @@ use radroots_nostr::prelude::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrPublicKey,
};
use radroots_nostr_accounts::prelude::{
+ RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore,
RadrootsNostrSecretVault, RadrootsNostrSecretVaultOsKeyring,
+ RadrootsNostrSelectedAccountStatus,
};
use serde::Serialize;
@@ -21,6 +24,14 @@ pub struct MycActiveIdentity {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycManagedAccountSelectionState {
+ NotConfigured,
+ PublicOnly,
+ Ready,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MycIdentityStatusOutput {
pub backend: MycIdentityBackend,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -35,6 +46,12 @@ pub struct MycIdentityStatusOutput {
pub inherited_from: Option<String>,
pub resolved: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected_account_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected_account_label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected_account_state: Option<MycManagedAccountSelectionState>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key_hex: Option<String>,
@@ -42,6 +59,27 @@ pub struct MycIdentityStatusOutput {
pub error: Option<String>,
}
+#[derive(Debug, Clone, Serialize)]
+pub struct MycManagedAccountsOutput {
+ pub role: String,
+ pub backend: MycIdentityBackend,
+ pub account_store_path: PathBuf,
+ pub keyring_service_name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected_account_id: Option<String>,
+ pub selected_account_state: MycManagedAccountSelectionState,
+ pub accounts: Vec<RadrootsNostrAccountRecord>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MycManagedAccountMutationOutput {
+ pub role: String,
+ pub action: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub account_id: Option<String>,
+ pub state: MycManagedAccountsOutput,
+}
+
#[derive(Clone)]
pub struct MycIdentityProvider {
role: String,
@@ -60,6 +98,11 @@ enum MycIdentityProviderBackend {
profile_path: Option<PathBuf>,
vault: Arc<dyn RadrootsNostrSecretVault>,
},
+ ManagedAccount {
+ account_store_path: PathBuf,
+ service_name: String,
+ manager: RadrootsNostrAccountsManager,
+ },
}
impl MycIdentityProvider {
@@ -97,6 +140,19 @@ impl MycIdentityProvider {
})?;
Self::vault_provider(role.as_str(), &source, account_id, service_name)?
}
+ MycIdentityBackend::ManagedAccount => {
+ let account_store_path = source.path.clone().ok_or_else(|| {
+ MycError::InvalidConfig(format!(
+ "{role} identity managed_account backend requires a path"
+ ))
+ })?;
+ let service_name = source.keyring_service_name.clone().ok_or_else(|| {
+ MycError::InvalidConfig(format!(
+ "{role} identity managed_account backend requires keyring_service_name"
+ ))
+ })?;
+ Self::managed_account_provider(role.as_str(), account_store_path, service_name)?
+ }
};
Ok(Self {
@@ -153,6 +209,41 @@ impl MycIdentityProvider {
}
Ok(identity)
}
+ MycIdentityProviderBackend::ManagedAccount {
+ account_store_path,
+ service_name,
+ manager,
+ } => match manager.selected_account_status().map_err(|source| {
+ MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ }
+ })? {
+ RadrootsNostrSelectedAccountStatus::NotConfigured => {
+ Err(MycError::CustodyManagedAccountNotConfigured {
+ role: self.role.clone(),
+ path: account_store_path.clone(),
+ })
+ }
+ RadrootsNostrSelectedAccountStatus::PublicOnly { account } => {
+ Err(MycError::CustodyManagedAccountPublicOnly {
+ role: self.role.clone(),
+ path: account_store_path.clone(),
+ service_name: service_name.clone(),
+ account_id: account.account_id.to_string(),
+ })
+ }
+ RadrootsNostrSelectedAccountStatus::Ready { .. } => manager
+ .selected_signing_identity()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?
+ .ok_or_else(|| MycError::CustodyManagedAccountNotConfigured {
+ role: self.role.clone(),
+ path: account_store_path.clone(),
+ }),
+ },
}
}
@@ -161,17 +252,126 @@ impl MycIdentityProvider {
}
pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput {
- self.status_with_result(Ok(identity.as_identity()))
+ match &self.backend {
+ MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status(
+ Ok(identity.as_identity()),
+ self.selected_managed_account_record_result(),
+ ),
+ _ => self.status_with_result(Ok(identity.as_identity())),
+ }
}
pub fn probe_status(&self) -> MycIdentityStatusOutput {
- self.status_with_result(self.load_identity().as_ref())
+ match &self.backend {
+ MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status(
+ self.load_identity().as_ref(),
+ self.selected_managed_account_record_result(),
+ ),
+ _ => self.status_with_result(self.load_identity().as_ref()),
+ }
}
pub fn source(&self) -> &MycIdentitySourceSpec {
&self.source
}
+ pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> {
+ self.managed_accounts_output()
+ }
+
+ pub fn generate_managed_account(
+ &self,
+ label: Option<String>,
+ make_selected: bool,
+ ) -> Result<MycManagedAccountMutationOutput, MycError> {
+ let account_id = {
+ let manager = self.managed_accounts_manager()?;
+ manager
+ .generate_identity(label, make_selected)
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?
+ };
+ Ok(MycManagedAccountMutationOutput {
+ role: self.role.clone(),
+ action: "generate".to_owned(),
+ account_id: Some(account_id.to_string()),
+ state: self.managed_accounts_output()?,
+ })
+ }
+
+ pub fn import_managed_account_file(
+ &self,
+ path: impl AsRef<std::path::Path>,
+ label: Option<String>,
+ make_selected: bool,
+ ) -> Result<MycManagedAccountMutationOutput, MycError> {
+ let account_id = {
+ let manager = self.managed_accounts_manager()?;
+ manager
+ .migrate_legacy_identity_file(path, label, make_selected)
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?
+ };
+ Ok(MycManagedAccountMutationOutput {
+ role: self.role.clone(),
+ action: "import_file".to_owned(),
+ account_id: Some(account_id.to_string()),
+ state: self.managed_accounts_output()?,
+ })
+ }
+
+ pub fn select_managed_account(
+ &self,
+ account_id: &str,
+ ) -> Result<MycManagedAccountMutationOutput, MycError> {
+ let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
+ MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
+ })?;
+ {
+ let manager = self.managed_accounts_manager()?;
+ manager
+ .select_account(&account_id)
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?;
+ }
+ Ok(MycManagedAccountMutationOutput {
+ role: self.role.clone(),
+ action: "select".to_owned(),
+ account_id: Some(account_id.to_string()),
+ state: self.managed_accounts_output()?,
+ })
+ }
+
+ pub fn remove_managed_account(
+ &self,
+ account_id: &str,
+ ) -> Result<MycManagedAccountMutationOutput, MycError> {
+ let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
+ MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
+ })?;
+ {
+ let manager = self.managed_accounts_manager()?;
+ manager
+ .remove_account(&account_id)
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?;
+ }
+ Ok(MycManagedAccountMutationOutput {
+ role: self.role.clone(),
+ action: "remove".to_owned(),
+ account_id: Some(account_id.to_string()),
+ state: self.managed_accounts_output()?,
+ })
+ }
+
fn status_with_result(
&self,
result: Result<&RadrootsIdentity, &MycError>,
@@ -185,6 +385,9 @@ impl MycIdentityProvider {
profile_path: self.source.profile_path.clone(),
inherited_from: None,
resolved: true,
+ selected_account_id: None,
+ selected_account_label: None,
+ selected_account_state: None,
identity_id: Some(identity.id().to_string()),
public_key_hex: Some(identity.public_key_hex()),
error: None,
@@ -197,6 +400,9 @@ impl MycIdentityProvider {
profile_path: self.source.profile_path.clone(),
inherited_from: None,
resolved: false,
+ selected_account_id: None,
+ selected_account_label: None,
+ selected_account_state: None,
identity_id: None,
public_key_hex: None,
error: Some(error.to_string()),
@@ -204,6 +410,185 @@ impl MycIdentityProvider {
}
}
+ fn selected_managed_account_record_result(
+ &self,
+ ) -> Result<Option<RadrootsNostrAccountRecord>, MycError> {
+ let manager = self.managed_accounts_manager()?;
+ manager
+ .selected_account()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })
+ }
+
+ fn managed_account_status(
+ &self,
+ identity_result: Result<&RadrootsIdentity, &MycError>,
+ account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>,
+ ) -> MycIdentityStatusOutput {
+ let MycIdentityProviderBackend::ManagedAccount {
+ account_store_path,
+ service_name,
+ manager,
+ } = &self.backend
+ else {
+ return self.status_with_result(identity_result);
+ };
+
+ let (selected_account_id, selected_account_label, identity_id, public_key_hex) =
+ match account_result {
+ Ok(Some(account)) => (
+ Some(account.account_id.to_string()),
+ account.label.clone(),
+ Some(account.account_id.to_string()),
+ Some(account.public_identity.public_key_hex),
+ ),
+ Ok(None) => (None, None, None, None),
+ Err(error) => {
+ return MycIdentityStatusOutput {
+ backend: self.source.backend,
+ path: Some(account_store_path.clone()),
+ keyring_account_id: None,
+ keyring_service_name: Some(service_name.clone()),
+ profile_path: None,
+ inherited_from: None,
+ resolved: false,
+ selected_account_id: None,
+ selected_account_label: None,
+ selected_account_state: None,
+ identity_id: None,
+ public_key_hex: None,
+ error: Some(error.to_string()),
+ };
+ }
+ };
+
+ let (resolved, selected_account_state, error) = match manager
+ .selected_account_status()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ }) {
+ Ok(RadrootsNostrSelectedAccountStatus::NotConfigured) => (
+ false,
+ Some(MycManagedAccountSelectionState::NotConfigured),
+ Some(
+ MycError::CustodyManagedAccountNotConfigured {
+ role: self.role.clone(),
+ path: account_store_path.clone(),
+ }
+ .to_string(),
+ ),
+ ),
+ Ok(RadrootsNostrSelectedAccountStatus::PublicOnly { account }) => (
+ false,
+ Some(MycManagedAccountSelectionState::PublicOnly),
+ Some(
+ MycError::CustodyManagedAccountPublicOnly {
+ role: self.role.clone(),
+ path: account_store_path.clone(),
+ service_name: service_name.clone(),
+ account_id: account.account_id.to_string(),
+ }
+ .to_string(),
+ ),
+ ),
+ Ok(RadrootsNostrSelectedAccountStatus::Ready { .. }) => match identity_result {
+ Ok(_) => (true, Some(MycManagedAccountSelectionState::Ready), None),
+ Err(error) => (
+ false,
+ Some(MycManagedAccountSelectionState::Ready),
+ Some(error.to_string()),
+ ),
+ },
+ Err(error) => (false, None, Some(error.to_string())),
+ };
+
+ MycIdentityStatusOutput {
+ backend: self.source.backend,
+ path: Some(account_store_path.clone()),
+ keyring_account_id: None,
+ keyring_service_name: Some(service_name.clone()),
+ profile_path: None,
+ inherited_from: None,
+ resolved,
+ selected_account_id,
+ selected_account_label,
+ selected_account_state,
+ identity_id,
+ public_key_hex,
+ error,
+ }
+ }
+
+ fn managed_accounts_output(&self) -> Result<MycManagedAccountsOutput, MycError> {
+ let MycIdentityProviderBackend::ManagedAccount {
+ account_store_path,
+ service_name,
+ manager,
+ } = &self.backend
+ else {
+ return Err(MycError::InvalidOperation(format!(
+ "{} identity backend `{}` does not support managed account lifecycle commands",
+ self.role,
+ self.source.backend.as_str(),
+ )));
+ };
+
+ let accounts = manager
+ .list_accounts()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?;
+ let selected_account_id = manager
+ .selected_account_id()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })?
+ .map(|value| value.to_string());
+ let selected_account_state =
+ match manager
+ .selected_account_status()
+ .map_err(|source| MycError::CustodyManager {
+ role: self.role.clone(),
+ source,
+ })? {
+ RadrootsNostrSelectedAccountStatus::NotConfigured => {
+ MycManagedAccountSelectionState::NotConfigured
+ }
+ RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => {
+ MycManagedAccountSelectionState::PublicOnly
+ }
+ RadrootsNostrSelectedAccountStatus::Ready { .. } => {
+ MycManagedAccountSelectionState::Ready
+ }
+ };
+
+ Ok(MycManagedAccountsOutput {
+ role: self.role.clone(),
+ backend: self.source.backend,
+ account_store_path: account_store_path.clone(),
+ keyring_service_name: service_name.clone(),
+ selected_account_id,
+ selected_account_state,
+ accounts,
+ })
+ }
+
+ fn managed_accounts_manager(&self) -> Result<&RadrootsNostrAccountsManager, MycError> {
+ match &self.backend {
+ MycIdentityProviderBackend::ManagedAccount { manager, .. } => Ok(manager),
+ _ => Err(MycError::InvalidOperation(format!(
+ "{} identity backend `{}` does not support managed account lifecycle commands",
+ self.role,
+ self.source.backend.as_str(),
+ ))),
+ }
+ }
+
fn vault_provider(
role: &str,
source: &MycIdentitySourceSpec,
@@ -222,6 +607,46 @@ impl MycIdentityProvider {
vault: Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name)),
})
}
+
+ fn managed_account_provider(
+ role: &str,
+ account_store_path: PathBuf,
+ service_name: String,
+ ) -> Result<MycIdentityProviderBackend, MycError> {
+ if account_store_path.as_os_str().is_empty() {
+ return Err(MycError::InvalidConfig(format!(
+ "{role} identity managed_account backend requires a non-empty path"
+ )));
+ }
+ if service_name.trim().is_empty() {
+ return Err(MycError::InvalidConfig(format!(
+ "{role} identity managed_account backend requires a non-empty keyring_service_name"
+ )));
+ }
+ if let Some(parent) = account_store_path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ let manager = RadrootsNostrAccountsManager::new(
+ Arc::new(RadrootsNostrFileAccountStore::new(
+ account_store_path.as_path(),
+ )),
+ Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name.clone())),
+ )
+ .map_err(|source| MycError::CustodyManager {
+ role: role.to_owned(),
+ source,
+ })?;
+ Ok(MycIdentityProviderBackend::ManagedAccount {
+ account_store_path,
+ service_name,
+ manager,
+ })
+ }
}
impl MycActiveIdentity {
@@ -350,10 +775,13 @@ impl MycIdentityStatusOutput {
#[cfg(test)]
mod tests {
- use std::path::Path;
+ use std::path::{Path, PathBuf};
use radroots_identity::RadrootsIdentity;
- use radroots_nostr_accounts::prelude::RadrootsNostrSecretVaultMemory;
+ use radroots_nostr_accounts::prelude::{
+ RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVault,
+ RadrootsNostrSecretVaultMemory,
+ };
use super::*;
@@ -374,6 +802,36 @@ mod tests {
}
}
+ fn managed_account_provider(
+ role: &str,
+ service_name: &str,
+ ) -> (MycIdentityProvider, Arc<RadrootsNostrSecretVaultMemory>) {
+ let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
+ let manager = RadrootsNostrAccountsManager::new(
+ Arc::new(RadrootsNostrMemoryAccountStore::new()),
+ vault.clone() as Arc<dyn RadrootsNostrSecretVault>,
+ )
+ .expect("manager");
+ (
+ MycIdentityProvider {
+ role: role.to_owned(),
+ source: MycIdentitySourceSpec {
+ backend: MycIdentityBackend::ManagedAccount,
+ path: Some(PathBuf::from(format!("/tmp/{role}-accounts.json"))),
+ keyring_account_id: None,
+ keyring_service_name: Some(service_name.to_owned()),
+ profile_path: None,
+ },
+ backend: MycIdentityProviderBackend::ManagedAccount {
+ account_store_path: PathBuf::from(format!("/tmp/{role}-accounts.json")),
+ service_name: service_name.to_owned(),
+ manager,
+ },
+ },
+ vault,
+ )
+ }
+
#[test]
fn filesystem_provider_loads_identity() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -459,4 +917,81 @@ mod tests {
assert!(matches!(err, MycError::CustodySecretNotFound { .. }));
assert!(!provider.probe_status().resolved);
}
+
+ #[test]
+ fn managed_account_provider_loads_selected_identity() {
+ let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
+ let generated = provider
+ .generate_managed_account(Some("primary".to_owned()), true)
+ .expect("generate");
+
+ let identity = provider.load_identity().expect("identity");
+ let identity_id = identity.id().to_string();
+ assert_eq!(
+ generated.state.selected_account_id.as_deref(),
+ Some(identity_id.as_str())
+ );
+ let status = provider.probe_status();
+ assert!(status.resolved);
+ assert_eq!(
+ status.selected_account_state,
+ Some(MycManagedAccountSelectionState::Ready)
+ );
+ }
+
+ #[test]
+ fn managed_account_provider_reports_not_configured() {
+ let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user");
+
+ let err = provider
+ .load_identity()
+ .expect_err("missing selected account");
+ assert!(matches!(
+ err,
+ MycError::CustodyManagedAccountNotConfigured { .. }
+ ));
+ let status = provider.probe_status();
+ assert!(!status.resolved);
+ assert_eq!(
+ status.selected_account_state,
+ Some(MycManagedAccountSelectionState::NotConfigured)
+ );
+ }
+
+ #[test]
+ fn managed_account_provider_reports_public_only_selected_account() {
+ let (provider, vault) = managed_account_provider("user", "org.radroots.test.user");
+ let identity = RadrootsIdentity::from_secret_key_str(
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ )
+ .expect("identity");
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("legacy.json");
+ identity.save_json(&path).expect("save");
+ let record = provider
+ .import_managed_account_file(&path, Some("legacy".to_owned()), true)
+ .expect("import");
+ let selected_account_id = record
+ .state
+ .selected_account_id
+ .clone()
+ .expect("selected account");
+ vault
+ .remove_secret(
+ &RadrootsIdentityId::parse(selected_account_id.as_str()).expect("account id"),
+ )
+ .expect("remove secret");
+
+ let err = provider.load_identity().expect_err("public only");
+ assert!(matches!(
+ err,
+ MycError::CustodyManagedAccountPublicOnly { .. }
+ ));
+ let status = provider.probe_status();
+ assert!(!status.resolved);
+ assert_eq!(
+ status.selected_account_state,
+ Some(MycManagedAccountSelectionState::PublicOnly)
+ );
+ }
}
diff --git a/src/error.rs b/src/error.rs
@@ -134,6 +134,12 @@ pub enum MycError {
#[source]
source: Box<MycError>,
},
+ #[error("custody manager error for {role} identity: {source}")]
+ CustodyManager {
+ role: String,
+ #[source]
+ source: RadrootsNostrAccountsError,
+ },
#[error("custody vault error for {role} identity: {source}")]
CustodyVault {
role: String,
@@ -166,6 +172,17 @@ pub enum MycError {
account_id: String,
profile_identity_id: String,
},
+ #[error("no selected managed account configured for {role} identity store {path}")]
+ CustodyManagedAccountNotConfigured { role: String, path: PathBuf },
+ #[error(
+ "selected managed account `{account_id}` in {path} for {role} identity has no secret in keyring service `{service_name}`"
+ )]
+ CustodyManagedAccountPublicOnly {
+ role: String,
+ path: PathBuf,
+ service_name: String,
+ account_id: String,
+ },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
diff --git a/src/lib.rs b/src/lib.rs
@@ -31,7 +31,10 @@ pub use config::{
MycTransportDeliveryPolicy,
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
-pub use custody::{MycActiveIdentity, MycIdentityProvider, MycIdentityStatusOutput};
+pub use custody::{
+ MycActiveIdentity, MycIdentityProvider, MycIdentityStatusOutput,
+ MycManagedAccountMutationOutput, MycManagedAccountSelectionState, MycManagedAccountsOutput,
+};
pub use discovery::{
MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus,