commit 5a8a172feb2f2fecf95cb2bb9e195b5d9a282d79
parent 77a641133b5545c59afad193775275376fd825aa
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 03:37:50 +0000
cli: make publish readiness signed-write aware
Diffstat:
| M | src/operation_core.rs | | | 102 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
| M | tests/target_cli.rs | | | 166 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
2 files changed, 240 insertions(+), 28 deletions(-)
diff --git a/src/operation_core.rs b/src/operation_core.rs
@@ -23,13 +23,14 @@ use crate::operation_adapter::{
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts::{
- account_resolution_view, account_summary_view, attach_identity_secret, clear_default_account,
- create_or_migrate_default_account, import_public_identity, preview_account_removal,
- preview_identity_secret_attachment, preview_public_identity_import, remove_account,
- resolve_account_resolution, resolve_account_selector, secret_backend_status, select_account,
- snapshot, unresolved_account_reason,
+ AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view,
+ attach_identity_secret, clear_default_account, create_or_migrate_default_account,
+ import_public_identity, preview_account_removal, preview_identity_secret_attachment,
+ preview_public_identity_import, remove_account, resolve_account_resolution,
+ resolve_account_selector, secret_backend_status, select_account, snapshot,
+ unresolved_account_reason,
};
-use crate::runtime::config::{PublishMode, RuntimeConfig};
+use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend};
use crate::runtime::logging::LoggingState;
use crate::runtime_args::LocalExportFormatArg;
@@ -100,7 +101,7 @@ impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> {
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
let store = map_runtime(crate::runtime::local::status(self.config))?;
let account = map_runtime(resolve_account_resolution(self.config))?;
- let publish = publish_runtime_view(self.config, false);
+ let publish = publish_runtime_view(self.config, true, &account);
json_operation_result::<HealthStatusGetResult>(json!({
"state": if store.state == "ready" { "ready" } else { "needs_attention" },
"store": store,
@@ -128,7 +129,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> {
} else {
Some(map_runtime(unresolved_account_reason(self.config))?)
};
- let publish = publish_runtime_view(self.config, false);
+ let publish = publish_runtime_view(self.config, true, &account);
json_operation_result::<HealthCheckRunResult>(json!({
"state": if store.state == "ready" && account.resolved_account.is_some() { "ready" } else { "needs_attention" },
"checks": {
@@ -163,6 +164,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> {
_request: OperationRequest<ConfigGetRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
let write_plane = crate::runtime::provider::resolve_write_plane_provider(self.config);
+ let account = map_runtime(resolve_account_resolution(self.config))?;
json_operation_result::<ConfigGetResult>(json!({
"output": {
"format": self.config.output.format.as_str(),
@@ -190,7 +192,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> {
"signer": {
"mode": self.config.signer.backend.as_str(),
},
- "publish": publish_runtime_view(self.config, false),
+ "publish": publish_runtime_view(self.config, true, &account),
"relay": {
"count": self.config.relay.urls.len(),
"urls": self.config.relay.urls,
@@ -684,7 +686,11 @@ fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig {
config
}
-fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) -> PublishRuntimeView {
+fn publish_runtime_view(
+ config: &RuntimeConfig,
+ signed_write_required: bool,
+ account: &AccountResolution,
+) -> PublishRuntimeView {
let relay_ready = !config.relay.urls.is_empty();
let source = config.publish.source.as_str().to_owned();
let relay = PublishRelayRuntimeView {
@@ -695,30 +701,20 @@ fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) ->
match config.publish.mode {
PublishMode::NostrRelay => {
- let reason = (!relay_ready).then(|| {
- "nostr_relay publish mode requires at least one configured relay for writes"
- .to_owned()
- });
+ let (state, executable, reason) =
+ nostr_relay_publish_readiness(config, relay_ready, signed_write_required, account);
PublishRuntimeView {
mode: config.publish.mode.as_str().to_owned(),
source,
transport_family: config.publish.mode.transport_family().to_owned(),
- state: if relay_ready {
- "ready".to_owned()
- } else {
- "unconfigured".to_owned()
- },
- executable: relay_ready,
+ state: state.to_owned(),
+ executable,
reason: reason.clone(),
signed_write_required,
relay,
provider: PublishProviderRuntimeView {
provider_runtime_id: "nostr_relay".to_owned(),
- state: if relay_ready {
- "ready".to_owned()
- } else {
- "unconfigured".to_owned()
- },
+ state: state.to_owned(),
source: config.relay.source.as_str().to_owned(),
reason,
},
@@ -748,6 +744,62 @@ fn publish_runtime_view(config: &RuntimeConfig, signed_write_required: bool) ->
}
}
+fn nostr_relay_publish_readiness(
+ config: &RuntimeConfig,
+ relay_ready: bool,
+ signed_write_required: bool,
+ account: &AccountResolution,
+) -> (&'static str, bool, Option<String>) {
+ if !relay_ready {
+ return (
+ "unconfigured",
+ false,
+ Some(
+ "nostr_relay publish mode requires at least one configured relay for writes"
+ .to_owned(),
+ ),
+ );
+ }
+
+ if !signed_write_required {
+ return ("ready", true, None);
+ }
+
+ if matches!(config.signer.backend, SignerBackend::Myc) {
+ return (
+ "unavailable",
+ false,
+ Some(
+ "nostr_relay publish mode requires signer mode `local` for signed writes; signer mode `myc` is deferred"
+ .to_owned(),
+ ),
+ );
+ }
+
+ let Some(resolved_account) = account.resolved_account.as_ref() else {
+ return (
+ "unconfigured",
+ false,
+ Some(
+ "nostr_relay publish mode requires a selected or default write-capable local account for signed writes"
+ .to_owned(),
+ ),
+ );
+ };
+
+ if !resolved_account.write_capable {
+ return (
+ "unconfigured",
+ false,
+ Some(
+ AccountRuntimeFailure::watch_only(&resolved_account.record.account_id).to_string(),
+ ),
+ );
+ }
+
+ ("ready", true, None)
+}
+
fn required_string<P>(
request: &OperationRequest<P>,
key: &str,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -6,9 +6,10 @@ use std::path::Path;
use serde_json::Value;
use support::{
- RadrootsCliSandbox, assert_no_daemon_runtime_reference, assert_no_removed_command_reference,
- create_listing_draft, identity_public, make_listing_publishable, ndjson_from_stdout, radroots,
- remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string,
+ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
+ assert_no_removed_command_reference, create_listing_draft, identity_public,
+ make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing,
+ replace_latest_listing_event_id, seed_orderable_listing, toml_string,
write_public_identity_profile,
};
@@ -105,6 +106,118 @@ fn config_get_exposes_resolved_publish_state() {
}
#[test]
+fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() {
+ let sandbox = RadrootsCliSandbox::new();
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19001",
+ "config",
+ "get",
+ ]);
+
+ assert_eq!(value["operation_id"], "config.get");
+ assert_eq!(value["result"]["publish"]["mode"], "nostr_relay");
+ assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
+ assert_eq!(value["result"]["publish"]["signed_write_required"], true);
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
+ assert_eq!(value["result"]["publish"]["executable"], false);
+ assert_contains(
+ &value["result"]["publish"]["reason"],
+ "write-capable local account",
+ );
+ assert_eq!(
+ value["result"]["publish"]["provider"]["state"],
+ "unconfigured"
+ );
+}
+
+#[test]
+fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19002",
+ "config",
+ "get",
+ ]);
+
+ assert_eq!(value["result"]["publish"]["mode"], "nostr_relay");
+ assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
+ assert_eq!(value["result"]["publish"]["signed_write_required"], true);
+ assert_eq!(value["result"]["publish"]["state"], "ready");
+ assert_eq!(value["result"]["publish"]["executable"], true);
+ assert_eq!(value["result"]["publish"]["reason"], Value::Null);
+ assert_eq!(value["result"]["publish"]["provider"]["state"], "ready");
+}
+
+#[test]
+fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ sandbox.write_app_config("[signer]\nmode = \"myc\"\n");
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19003",
+ "config",
+ "get",
+ ]);
+
+ assert_eq!(value["result"]["publish"]["mode"], "nostr_relay");
+ assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
+ assert_eq!(value["result"]["publish"]["signed_write_required"], true);
+ assert_eq!(value["result"]["publish"]["state"], "unavailable");
+ assert_eq!(value["result"]["publish"]["executable"], false);
+ assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`");
+ assert_eq!(
+ value["result"]["publish"]["provider"]["state"],
+ "unavailable"
+ );
+}
+
+#[test]
+fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() {
+ let sandbox = RadrootsCliSandbox::new();
+ let public_identity = identity_public(41);
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "publish-readiness-watch-only", &public_identity);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19004",
+ "config",
+ "get",
+ ]);
+
+ assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
+ assert_eq!(value["result"]["publish"]["signed_write_required"], true);
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
+ assert_eq!(value["result"]["publish"]["executable"], false);
+ assert_contains(&value["result"]["publish"]["reason"], "watch_only");
+}
+
+#[test]
fn health_surfaces_publish_state_under_deferred_signer_mode() {
let sandbox = RadrootsCliSandbox::new();
let missing_myc = sandbox.root().join("bin/missing-myc");
@@ -126,6 +239,31 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() {
}
#[test]
+fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() {
+ let sandbox = RadrootsCliSandbox::new();
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19005",
+ "health",
+ "status",
+ "get",
+ ]);
+
+ assert_eq!(value["operation_id"], "health.status.get");
+ assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
+ assert_eq!(value["result"]["publish"]["signed_write_required"], true);
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
+ assert_eq!(value["result"]["publish"]["executable"], false);
+ assert_contains(
+ &value["result"]["publish"]["reason"],
+ "write-capable local account",
+ );
+}
+
+#[test]
fn health_check_exposes_publish_readiness() {
let sandbox = RadrootsCliSandbox::new();
sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n");
@@ -140,6 +278,28 @@ fn health_check_exposes_publish_readiness() {
}
#[test]
+fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ "ws://127.0.0.1:19006",
+ "health",
+ "check",
+ "run",
+ ]);
+
+ assert_eq!(value["operation_id"], "health.check.run");
+ assert_eq!(value["result"]["checks"]["publish"]["mode"], "nostr_relay");
+ assert_eq!(value["result"]["checks"]["publish"]["state"], "ready");
+ assert_eq!(value["result"]["checks"]["publish"]["executable"], true);
+ assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
+}
+
+#[test]
fn radrootsd_publish_mode_fails_closed_for_direct_relay_publish_paths() {
let sandbox = RadrootsCliSandbox::new();
let missing_listing = sandbox.root().join("missing-listing.toml");