commit 4b72b37d429104cffa4e32aa196ceb97f8a38ff5
parent 15933f42a2649bbd1a034dc6d88992806b06675d
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 00:06:25 +0000
app: add operator control commands
- add explicit connection approval policy and reload signer state from disk so operator changes reach the live listener
- add repo-local CLI commands for connection lifecycle, audit inspection, and auth challenge management
- support client-initiated nostrconnect acceptance and authorized request replay over the configured transport
- validate with cargo fmt --check, cargo check --locked, cargo test --locked, cargo run -- --help, and cargo run -- connect accept --help
Diffstat:
10 files changed, 727 insertions(+), 48 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -51,6 +51,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -274,6 +324,52 @@ dependencies = [
]
[[package]]
+name = "clap"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
name = "config"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -813,6 +909,12 @@ dependencies = [
]
[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -917,6 +1019,7 @@ dependencies = [
name = "myc"
version = "0.1.0"
dependencies = [
+ "clap",
"nostr",
"radroots-identity",
"radroots-nostr",
@@ -1057,6 +1160,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1685,6 +1794,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2118,6 +2233,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
name = "uuid"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -14,6 +14,7 @@ resolver = "2"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
[dependencies]
+clap = { version = "4.5", features = ["derive"] }
nostr = { version = "0.44.2", features = ["nip04", "nip44"] }
radroots-identity = { path = "../lib/crates/identity" }
radroots-nostr = { path = "../lib/crates/nostr", features = ["client"] }
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -1,12 +1,15 @@
use std::fs;
-use std::path::PathBuf;
-use std::sync::Arc;
+use std::path::{Path, PathBuf};
use crate::config::MycConfig;
use crate::error::MycError;
use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
-use radroots_nostr_signer::prelude::{RadrootsNostrFileSignerStore, RadrootsNostrSignerManager};
+use radroots_nostr_signer::prelude::{
+ RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement,
+ RadrootsNostrSignerManager,
+};
+use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MycRuntimePaths {
@@ -37,7 +40,8 @@ pub struct MycStartupSnapshot {
pub struct MycSignerContext {
signer_identity: RadrootsIdentity,
user_identity: RadrootsIdentity,
- manager: RadrootsNostrSignerManager,
+ signer_state_path: PathBuf,
+ connection_approval_requirement: RadrootsNostrSignerApprovalRequirement,
}
#[derive(Clone)]
@@ -54,7 +58,13 @@ impl MycRuntime {
let paths = MycRuntimePaths::from_config(&config);
Self::prepare_filesystem_for(&paths)?;
- let signer = MycSignerContext::bootstrap(&paths)?;
+ let signer = MycSignerContext::bootstrap(
+ &paths,
+ config
+ .policy
+ .connection_approval
+ .into_signer_approval_requirement(),
+ )?;
let transport = MycNostrTransport::bootstrap(&config.transport, &signer.signer_identity)?;
let runtime = Self {
paths,
@@ -89,8 +99,8 @@ impl MycRuntime {
self.signer.user_public_identity()
}
- pub fn signer_manager(&self) -> &RadrootsNostrSignerManager {
- self.signer.signer_manager()
+ pub fn signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> {
+ self.signer.load_signer_manager()
}
pub fn transport(&self) -> Option<&MycNostrTransport> {
@@ -192,16 +202,21 @@ impl MycSignerContext {
self.user_identity.to_public()
}
- pub fn signer_manager(&self) -> &RadrootsNostrSignerManager {
- &self.manager
+ pub fn load_signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> {
+ Self::load_signer_manager_from_path(&self.signer_state_path)
+ }
+
+ pub fn connection_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement {
+ self.connection_approval_requirement
}
- fn bootstrap(paths: &MycRuntimePaths) -> Result<Self, MycError> {
+ fn bootstrap(
+ paths: &MycRuntimePaths,
+ connection_approval_requirement: RadrootsNostrSignerApprovalRequirement,
+ ) -> Result<Self, MycError> {
let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?;
let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?;
- let manager = RadrootsNostrSignerManager::new(Arc::new(
- RadrootsNostrFileSignerStore::new(&paths.signer_state_path),
- ))?;
+ let manager = Self::load_signer_manager_from_path(&paths.signer_state_path)?;
let configured_public = signer_identity.to_public();
match manager.signer_identity()? {
@@ -220,9 +235,16 @@ impl MycSignerContext {
Ok(Self {
signer_identity,
user_identity,
- manager,
+ signer_state_path: paths.signer_state_path.clone(),
+ connection_approval_requirement,
})
}
+
+ fn load_signer_manager_from_path(path: &Path) -> Result<RadrootsNostrSignerManager, MycError> {
+ Ok(RadrootsNostrSignerManager::new(Arc::new(
+ RadrootsNostrFileSignerStore::new(path),
+ ))?)
+ }
}
#[cfg(test)]
@@ -284,6 +306,7 @@ mod tests {
assert_eq!(
runtime
.signer_manager()
+ .expect("manager")
.signer_identity()
.expect("signer identity")
.expect("configured signer")
diff --git a/src/cli.rs b/src/cli.rs
@@ -0,0 +1,395 @@
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+
+use clap::{Args, Parser, Subcommand};
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectResponse, RadrootsNostrConnectUri,
+};
+use radroots_nostr_signer::prelude::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthorizationOutcome,
+ RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord,
+ RadrootsNostrSignerRequestId,
+};
+use serde::Serialize;
+
+use crate::app::MycRuntime;
+use crate::config::{DEFAULT_CONFIG_PATH, MycConfig};
+use crate::error::MycError;
+use crate::logging;
+use crate::transport::{MycNip46Handler, MycNostrTransport};
+
+#[derive(Debug, Parser)]
+#[command(name = "myc")]
+#[command(about = "Mycorrhiza NIP-46 signer service")]
+pub struct MycCli {
+ #[arg(long, global = true)]
+ config: Option<PathBuf>,
+ #[command(subcommand)]
+ command: Option<MycCommand>,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum MycCommand {
+ Run,
+ Connections {
+ #[command(subcommand)]
+ command: MycConnectionsCommand,
+ },
+ Audit {
+ #[command(subcommand)]
+ command: MycAuditCommand,
+ },
+ Auth {
+ #[command(subcommand)]
+ command: MycAuthCommand,
+ },
+ Connect {
+ #[command(subcommand)]
+ command: MycConnectCommand,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+pub enum MycConnectionsCommand {
+ List,
+ Approve(MycConnectionApprovalArgs),
+ Reject(MycConnectionReasonArgs),
+ Revoke(MycConnectionReasonArgs),
+}
+
+#[derive(Debug, Subcommand)]
+pub enum MycAuditCommand {
+ List {
+ #[arg(long)]
+ connection_id: Option<String>,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+pub enum MycAuthCommand {
+ Require {
+ #[arg(long)]
+ connection_id: String,
+ #[arg(long)]
+ url: String,
+ },
+ Authorize {
+ #[arg(long)]
+ connection_id: String,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+pub enum MycConnectCommand {
+ Accept {
+ #[arg(long)]
+ uri: String,
+ },
+}
+
+#[derive(Debug, Args)]
+pub struct MycConnectionApprovalArgs {
+ #[arg(long)]
+ connection_id: String,
+ #[arg(long = "grant")]
+ grants: Vec<String>,
+}
+
+#[derive(Debug, Args)]
+pub struct MycConnectionReasonArgs {
+ #[arg(long)]
+ connection_id: String,
+ #[arg(long)]
+ reason: Option<String>,
+}
+
+#[derive(Debug, Serialize)]
+struct MycAuthorizedReplayOutput {
+ connection: RadrootsNostrSignerConnectionRecord,
+ replayed_request_id: Option<String>,
+}
+
+#[derive(Debug, Serialize)]
+struct MycAcceptedConnectionOutput {
+ connection: RadrootsNostrSignerConnectionRecord,
+ response_request_id: String,
+ response_relays: Vec<String>,
+}
+
+pub async fn run_from_env() -> Result<(), MycError> {
+ let cli = MycCli::parse();
+ let config = load_config(cli.config.as_deref())?;
+
+ match cli.command.unwrap_or(MycCommand::Run) {
+ MycCommand::Run => {
+ logging::init_logging(&config.logging)?;
+ MycRuntime::bootstrap(config)?.run().await
+ }
+ MycCommand::Connections { command } => {
+ let runtime = MycRuntime::bootstrap(config)?;
+ match command {
+ MycConnectionsCommand::List => {
+ let manager = runtime.signer_manager()?;
+ print_json(&manager.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(
+ &manager.list_connections()?,
+ &connection_id,
+ &args.grants,
+ )?;
+ let connection =
+ manager.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)?;
+ 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)?;
+ print_json(&connection)
+ }
+ }
+ }
+ MycCommand::Audit { command } => {
+ let runtime = MycRuntime::bootstrap(config)?;
+ let manager = runtime.signer_manager()?;
+ match command {
+ MycAuditCommand::List { connection_id } => {
+ if let Some(connection_id) = connection_id {
+ let connection_id = parse_connection_id(&connection_id)?;
+ print_json(&manager.audit_records_for_connection(&connection_id)?)
+ } else {
+ print_json(&manager.list_audit_records()?)
+ }
+ }
+ }
+ }
+ MycCommand::Auth { command } => {
+ let runtime = MycRuntime::bootstrap(config)?;
+ 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)?;
+ print_json(&connection)
+ }
+ MycAuthCommand::Authorize { connection_id } => {
+ let connection_id = parse_connection_id(&connection_id)?;
+ let outcome = runtime
+ .signer_manager()?
+ .authorize_auth_challenge(&connection_id)?;
+ let replayed_request_id = replay_authorized_request(&runtime, &outcome).await?;
+ print_json(&MycAuthorizedReplayOutput {
+ connection: outcome.connection,
+ replayed_request_id,
+ })
+ }
+ }
+ }
+ MycCommand::Connect { command } => {
+ let runtime = MycRuntime::bootstrap(config)?;
+ match command {
+ MycConnectCommand::Accept { uri } => {
+ let accepted = accept_client_uri(&runtime, &uri).await?;
+ print_json(&accepted)
+ }
+ }
+ }
+ }
+}
+
+fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> {
+ match path {
+ Some(path) => MycConfig::load_from_path_if_exists(path),
+ None => MycConfig::load_from_path_if_exists(DEFAULT_CONFIG_PATH),
+ }
+}
+
+fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> {
+ Ok(RadrootsNostrSignerConnectionId::parse(value)?)
+}
+
+fn parse_permission_values(values: &[String]) -> Result<RadrootsNostrConnectPermissions, MycError> {
+ let mut permissions = Vec::new();
+ for value in values {
+ for fragment in value.split(',') {
+ let trimmed = fragment.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ permissions.push(RadrootsNostrConnectPermission::from_str(trimmed)?);
+ }
+ }
+ permissions.sort();
+ permissions.dedup();
+ Ok(permissions.into())
+}
+
+fn granted_permissions_for_approval(
+ connections: &[RadrootsNostrSignerConnectionRecord],
+ connection_id: &RadrootsNostrSignerConnectionId,
+ grants: &[String],
+) -> Result<RadrootsNostrConnectPermissions, MycError> {
+ if !grants.is_empty() {
+ return parse_permission_values(grants);
+ }
+
+ let connection = connections
+ .iter()
+ .find(|connection| &connection.connection_id == connection_id)
+ .ok_or_else(|| {
+ MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
+ })?;
+ Ok(connection.requested_permissions.clone())
+}
+
+async fn replay_authorized_request(
+ runtime: &MycRuntime,
+ outcome: &RadrootsNostrSignerAuthorizationOutcome,
+) -> Result<Option<String>, MycError> {
+ let Some(pending_request) = &outcome.pending_request else {
+ return Ok(None);
+ };
+ let transport = runtime.transport().ok_or_else(|| {
+ MycError::InvalidOperation(
+ "transport.enabled must be true to replay authorized requests".to_owned(),
+ )
+ })?;
+ let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec());
+ let response = handler.handle_request(
+ outcome.connection.client_public_key,
+ pending_request.request_message.clone(),
+ )?;
+ let event = handler.build_response_event(
+ outcome.connection.client_public_key,
+ pending_request.request_message.id.clone(),
+ response,
+ )?;
+ let publish_relays = if outcome.connection.relays.is_empty() {
+ transport.relays().to_vec()
+ } else {
+ outcome.connection.relays.clone()
+ };
+ MycNostrTransport::publish_once(
+ runtime.signer_identity(),
+ &publish_relays,
+ transport.connect_timeout_secs(),
+ event,
+ )
+ .await?;
+ Ok(Some(pending_request.request_message.id.clone()))
+}
+
+async fn accept_client_uri(
+ runtime: &MycRuntime,
+ uri: &str,
+) -> Result<MycAcceptedConnectionOutput, MycError> {
+ let Some(transport) = runtime.transport() else {
+ return Err(MycError::InvalidOperation(
+ "transport.enabled must be true to accept client nostrconnect URIs".to_owned(),
+ ));
+ };
+ let preferred_relays = transport.relays().to_vec();
+ if preferred_relays.is_empty() {
+ return Err(MycError::InvalidOperation(
+ "transport.relays must not be empty to accept client nostrconnect URIs".to_owned(),
+ ));
+ }
+
+ let client_uri = match RadrootsNostrConnectUri::parse(uri)? {
+ RadrootsNostrConnectUri::Client(client_uri) => client_uri,
+ RadrootsNostrConnectUri::Bunker(_) => {
+ return Err(MycError::InvalidOperation(
+ "connect accept requires a nostrconnect:// client URI".to_owned(),
+ ));
+ }
+ };
+
+ let request = RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: runtime.signer_identity().public_key(),
+ secret: Some(client_uri.secret.clone()),
+ requested_permissions: client_uri.metadata.requested_permissions.clone(),
+ };
+ let manager = runtime.signer_manager()?;
+ let proposal = match manager.evaluate_connect_request(client_uri.client_public_key, request)? {
+ radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => {
+ return Err(MycError::InvalidOperation(
+ "connect secret is already bound to an existing connection".to_owned(),
+ ));
+ }
+ radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired(
+ proposal,
+ ) => proposal,
+ };
+
+ let draft = proposal
+ .into_connection_draft(runtime.user_public_identity())
+ .with_relays(preferred_relays.clone())
+ .with_approval_requirement(runtime.signer_context().connection_approval_requirement());
+ let connection = manager.register_connection(draft)?;
+ if runtime.signer_context().connection_approval_requirement()
+ == RadrootsNostrSignerApprovalRequirement::NotRequired
+ {
+ let _ = manager.set_granted_permissions(
+ &connection.connection_id,
+ connection.requested_permissions.clone(),
+ )?;
+ }
+
+ let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone());
+ let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string();
+ let event = handler.build_response_event(
+ client_uri.client_public_key,
+ response_request_id.clone(),
+ RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret),
+ )?;
+ let response_relays = merge_relays(&client_uri.relays, &preferred_relays);
+ MycNostrTransport::publish_once(
+ runtime.signer_identity(),
+ &response_relays,
+ transport.connect_timeout_secs(),
+ event,
+ )
+ .await?;
+
+ Ok(MycAcceptedConnectionOutput {
+ connection: runtime
+ .signer_manager()?
+ .list_connections()?
+ .into_iter()
+ .find(|record| record.connection_id == connection.connection_id)
+ .ok_or_else(|| {
+ MycError::InvalidOperation("accepted connection was not persisted".to_owned())
+ })?,
+ response_request_id,
+ response_relays: response_relays.iter().map(ToString::to_string).collect(),
+ })
+}
+
+fn merge_relays(
+ primary: &[nostr::RelayUrl],
+ secondary: &[nostr::RelayUrl],
+) -> Vec<nostr::RelayUrl> {
+ let mut relays = primary.to_vec();
+ relays.extend_from_slice(secondary);
+ relays.sort_by(|left, right| left.as_str().cmp(right.as_str()));
+ relays.dedup_by(|left, right| left.as_str() == right.as_str());
+ relays
+}
+
+fn print_json<T>(value: &T) -> Result<(), MycError>
+where
+ T: Serialize,
+{
+ println!("{}", serde_json::to_string_pretty(value)?);
+ Ok(())
+}
diff --git a/src/config.rs b/src/config.rs
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use radroots_identity::DEFAULT_IDENTITY_PATH;
use radroots_nostr::prelude::RadrootsNostrRelayUrl;
+use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement;
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
@@ -16,6 +17,7 @@ pub struct MycConfig {
pub service: MycServiceConfig,
pub logging: MycLoggingConfig,
pub paths: MycPathsConfig,
+ pub policy: MycPolicyConfig,
pub transport: MycTransportConfig,
}
@@ -47,12 +49,26 @@ pub struct MycTransportConfig {
pub relays: Vec<String>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycConnectionApproval {
+ NotRequired,
+ ExplicitUser,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct MycPolicyConfig {
+ pub connection_approval: MycConnectionApproval,
+}
+
impl Default for MycConfig {
fn default() -> Self {
Self {
service: MycServiceConfig::default(),
logging: MycLoggingConfig::default(),
paths: MycPathsConfig::default(),
+ policy: MycPolicyConfig::default(),
transport: MycTransportConfig::default(),
}
}
@@ -94,6 +110,23 @@ impl Default for MycTransportConfig {
}
}
+impl Default for MycPolicyConfig {
+ fn default() -> Self {
+ Self {
+ connection_approval: MycConnectionApproval::ExplicitUser,
+ }
+ }
+}
+
+impl MycConnectionApproval {
+ pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement {
+ match self {
+ Self::NotRequired => RadrootsNostrSignerApprovalRequirement::NotRequired,
+ Self::ExplicitUser => RadrootsNostrSignerApprovalRequirement::ExplicitUser,
+ }
+ }
+}
+
impl MycConfig {
pub fn load_from_default_path_if_exists() -> Result<Self, MycError> {
Self::load_from_path_if_exists(DEFAULT_CONFIG_PATH)
@@ -220,6 +253,10 @@ mod tests {
config.paths.user_identity_path,
PathBuf::from(DEFAULT_IDENTITY_PATH)
);
+ assert_eq!(
+ config.policy.connection_approval,
+ MycConnectionApproval::ExplicitUser
+ );
assert!(!config.transport.enabled);
assert_eq!(config.transport.connect_timeout_secs, 10);
assert!(config.transport.relays.is_empty());
@@ -240,6 +277,9 @@ mod tests {
signer_identity_path = "/tmp/myc-identity.json"
user_identity_path = "/tmp/myc-user.json"
+ [policy]
+ connection_approval = "not_required"
+
[transport]
enabled = true
connect_timeout_secs = 15
@@ -259,6 +299,10 @@ mod tests {
config.paths.user_identity_path,
PathBuf::from("/tmp/myc-user.json")
);
+ assert_eq!(
+ config.policy.connection_approval,
+ MycConnectionApproval::NotRequired
+ );
assert!(config.transport.enabled);
assert_eq!(config.transport.connect_timeout_secs, 15);
assert_eq!(
diff --git a/src/error.rs b/src/error.rs
@@ -22,6 +22,8 @@ pub enum MycError {
},
#[error("invalid config: {0}")]
InvalidConfig(String),
+ #[error("invalid operation: {0}")]
+ InvalidOperation(String),
#[error("invalid log filter `{filter}`: {source}")]
InvalidLogFilter {
filter: String,
@@ -44,6 +46,8 @@ pub enum MycError {
NostrConnect(#[from] RadrootsNostrConnectError),
#[error(transparent)]
SignerState(#[from] RadrootsNostrSignerError),
+ #[error(transparent)]
+ Json(#[from] serde_json::Error),
#[error("NIP-46 decrypt failed: {0}")]
Nip46Decrypt(String),
#[error("NIP-46 encrypt failed: {0}")]
diff --git a/src/lib.rs b/src/lib.rs
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
pub mod app;
+pub mod cli;
pub mod config;
pub mod error;
pub mod logging;
@@ -8,8 +9,8 @@ pub mod transport;
pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot};
pub use config::{
- DEFAULT_CONFIG_PATH, MycConfig, MycLoggingConfig, MycPathsConfig, MycServiceConfig,
- MycTransportConfig,
+ DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig,
+ MycPolicyConfig, MycServiceConfig, MycTransportConfig,
};
pub use error::MycError;
pub use transport::{MycNostrTransport, MycTransportSnapshot};
@@ -19,3 +20,7 @@ pub async fn run() -> Result<(), MycError> {
logging::init_logging(&config.logging)?;
MycApp::bootstrap(config)?.run().await
}
+
+pub async fn run_cli() -> Result<(), MycError> {
+ cli::run_from_env().await
+}
diff --git a/src/main.rs b/src/main.rs
@@ -2,7 +2,7 @@
#[tokio::main]
async fn main() {
- if let Err(err) = myc::run().await {
+ if let Err(err) = myc::run_cli().await {
eprintln!("myc: {err}");
std::process::exit(1);
}
diff --git a/src/transport.rs b/src/transport.rs
@@ -3,7 +3,9 @@ pub mod nip46;
use std::time::Duration;
use radroots_identity::RadrootsIdentity;
-use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrRelayUrl};
+use radroots_nostr::prelude::{
+ RadrootsNostrClient, RadrootsNostrEventBuilder, RadrootsNostrRelayUrl,
+};
use crate::config::MycTransportConfig;
use crate::error::MycError;
@@ -63,6 +65,30 @@ impl MycNostrTransport {
Ok(())
}
+ pub async fn publish_once(
+ signer_identity: &RadrootsIdentity,
+ relays: &[RadrootsNostrRelayUrl],
+ connect_timeout_secs: u64,
+ event: RadrootsNostrEventBuilder,
+ ) -> Result<(), MycError> {
+ if relays.is_empty() {
+ return Err(MycError::InvalidOperation(
+ "cannot publish without at least one relay".to_owned(),
+ ));
+ }
+
+ let client = RadrootsNostrClient::from_identity(signer_identity);
+ for relay in relays {
+ let _ = client.add_relay(relay.as_str()).await?;
+ }
+ client.connect().await;
+ client
+ .wait_for_connection(Duration::from_secs(connect_timeout_secs))
+ .await;
+ let _ = client.send_event_builder(event).await?;
+ Ok(())
+ }
+
pub fn snapshot(&self) -> MycTransportSnapshot {
MycTransportSnapshot {
enabled: true,
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -127,10 +127,8 @@ impl MycNip46Handler {
request: RadrootsNostrConnectRequest,
secret: Option<String>,
) -> Result<RadrootsNostrConnectResponse, MycError> {
- let evaluation = self
- .signer
- .signer_manager()
- .evaluate_connect_request(client_public_key, request)?;
+ let manager = self.signer.load_signer_manager()?;
+ let evaluation = manager.evaluate_connect_request(client_public_key, request)?;
match evaluation {
RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => {
@@ -139,14 +137,19 @@ impl MycNip46Handler {
RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => {
let draft = proposal
.into_connection_draft(self.signer.user_public_identity())
- .with_relays(self.relays.clone());
- let connection = self.signer.signer_manager().register_connection(draft)?;
- let granted_permissions =
- grant_permissions_for_new_connection(connection.requested_permissions.clone());
- let _ = self
- .signer
- .signer_manager()
- .set_granted_permissions(&connection.connection_id, granted_permissions)?;
+ .with_relays(self.relays.clone())
+ .with_approval_requirement(self.signer.connection_approval_requirement());
+ let connection = manager.register_connection(draft)?;
+ if self.signer.connection_approval_requirement()
+ == radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement::NotRequired
+ {
+ let granted_permissions =
+ grant_permissions_for_new_connection(connection.requested_permissions.clone());
+ let _ = manager.set_granted_permissions(
+ &connection.connection_id,
+ granted_permissions,
+ )?;
+ }
Ok(connect_response(secret))
}
}
@@ -162,10 +165,8 @@ impl MycNip46Handler {
Err(response) => return Ok(response),
};
- let evaluation = self
- .signer
- .signer_manager()
- .evaluate_request(&connection.connection_id, request_message)?;
+ let manager = self.signer.load_signer_manager()?;
+ let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
RadrootsNostrSignerRequestAction::Denied { reason } => {
@@ -194,10 +195,8 @@ impl MycNip46Handler {
Err(response) => return Ok(response),
};
- let evaluation = self
- .signer
- .signer_manager()
- .evaluate_request(&connection.connection_id, request_message)?;
+ let manager = self.signer.load_signer_manager()?;
+ let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
RadrootsNostrSignerRequestAction::Denied { reason } => {
@@ -226,10 +225,8 @@ impl MycNip46Handler {
Err(response) => return Ok(response),
};
- let evaluation = self
- .signer
- .signer_manager()
- .evaluate_request(&connection.connection_id, request_message)?;
+ let manager = self.signer.load_signer_manager()?;
+ let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?;
match evaluation.action {
RadrootsNostrSignerRequestAction::Denied { reason } => {
@@ -253,7 +250,7 @@ impl MycNip46Handler {
Ok(
match self
.signer
- .signer_manager()
+ .load_signer_manager()?
.lookup_session(&client_public_key, None)?
{
RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(connection),
@@ -472,7 +469,7 @@ mod tests {
use serde_json::json;
use crate::app::MycRuntime;
- use crate::config::MycConfig;
+ use crate::config::{MycConfig, MycConnectionApproval};
use super::MycNip46Handler;
@@ -484,11 +481,33 @@ mod tests {
}
fn runtime() -> MycRuntime {
- let temp = tempfile::tempdir().expect("tempdir");
+ let temp = tempfile::tempdir().expect("tempdir").keep();
+ let mut config = MycConfig::default();
+ config.paths.state_dir = temp.join("state");
+ config.paths.signer_identity_path = temp.join("signer.json");
+ config.paths.user_identity_path = temp.join("user.json");
+ config.policy.connection_approval = MycConnectionApproval::NotRequired;
+ config.transport.enabled = true;
+ config.transport.connect_timeout_secs = 15;
+ config.transport.relays = vec!["wss://relay.example.com".to_owned()];
+ write_identity(
+ &config.paths.signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &config.paths.user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ MycRuntime::bootstrap(config).expect("runtime")
+ }
+
+ fn runtime_with_explicit_approval() -> MycRuntime {
+ let temp = tempfile::tempdir().expect("tempdir").keep();
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");
+ config.paths.state_dir = temp.join("state");
+ config.paths.signer_identity_path = temp.join("signer.json");
+ config.paths.user_identity_path = temp.join("user.json");
+ config.policy.connection_approval = MycConnectionApproval::ExplicitUser;
config.transport.enabled = true;
config.transport.connect_timeout_secs = 15;
config.transport.relays = vec!["wss://relay.example.com".to_owned()];
@@ -643,6 +662,7 @@ mod tests {
);
let connections = runtime
.signer_manager()
+ .expect("manager")
.list_connections()
.expect("connections");
assert_eq!(connections.len(), 1);
@@ -654,6 +674,45 @@ mod tests {
}
#[test]
+ fn connect_preserves_pending_status_when_explicit_approval_is_required() {
+ let runtime = runtime_with_explicit_approval();
+ let handler = handler(&runtime);
+
+ let response = handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: runtime.signer_identity().public_key(),
+ secret: None,
+ requested_permissions: vec![sign_event_permission(1)].into(),
+ },
+ ),
+ )
+ .expect("connect response");
+
+ assert_eq!(response, RadrootsNostrConnectResponse::ConnectAcknowledged);
+ let connection = runtime
+ .signer_manager()
+ .expect("manager")
+ .list_connections()
+ .expect("connections")
+ .into_iter()
+ .next()
+ .expect("connection");
+ assert_eq!(
+ connection.status,
+ radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionStatus::Pending
+ );
+ assert_eq!(
+ connection.approval_state,
+ radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalState::Pending
+ );
+ assert!(connection.granted_permissions().as_slice().is_empty());
+ }
+
+ #[test]
fn base_methods_return_spec_results_after_connect() {
let runtime = runtime();
let handler = handler(&runtime);
@@ -736,6 +795,7 @@ mod tests {
let connection = runtime
.signer_manager()
+ .expect("manager")
.list_connections()
.expect("connections")
.into_iter()