commit 488476f70d1b3d18bfacf609dd51d47b63f6519d
parent 74b1e7d6e82b6db20eceb3ea659400d165a63e5f
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 02:35:00 +0000
cli: type account write readiness
- render account summaries from runtime custody facts
- stop deriving write capability from signer labels
- map missing listing seller accounts through typed validation
- cover account and listing gate behavior with focused tests
Diffstat:
3 files changed, 109 insertions(+), 43 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -451,22 +451,18 @@ pub struct AccountSummaryView {
}
impl AccountSummaryView {
- pub fn from_account_record(
+ pub fn from_account_runtime(
record: &RadrootsNostrAccountRecord,
signer: &str,
+ custody: &str,
+ write_capable: bool,
is_default: bool,
) -> Self {
- let write_capable = signer == "local";
Self {
id: record.account_id.to_string(),
display_name: record.label.clone(),
signer: signer.to_owned(),
- custody: if write_capable {
- "secret_backed"
- } else {
- "watch_only"
- }
- .to_owned(),
+ custody: custody.to_owned(),
write_capable,
is_default,
}
diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs
@@ -66,7 +66,36 @@ pub struct AccountSnapshot {
pub struct AccountRecordView {
pub record: RadrootsNostrAccountRecord,
pub is_default: bool,
- pub signer: &'static str,
+ pub custody: AccountCustody,
+ pub write_capable: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AccountCustody {
+ SecretBacked,
+ WatchOnly,
+}
+
+impl AccountCustody {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::SecretBacked => "secret_backed",
+ Self::WatchOnly => "watch_only",
+ }
+ }
+
+ pub fn signer_label(self) -> &'static str {
+ match self {
+ Self::SecretBacked => "local",
+ Self::WatchOnly => "watch_only",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct AccountRuntimeFacts {
+ custody: AccountCustody,
+ write_capable: bool,
}
#[derive(Debug, Clone)]
@@ -207,7 +236,8 @@ pub fn preview_public_identity_import(
Ok(AccountRecordView {
record: RadrootsNostrAccountRecord::new(public_identity, None, 0),
is_default: make_default,
- signer: "watch_only",
+ custody: AccountCustody::WatchOnly,
+ write_capable: false,
})
}
@@ -225,7 +255,8 @@ pub fn preview_identity_secret_attachment(
if make_default {
account.is_default = true;
}
- account.signer = "local";
+ account.custody = AccountCustody::SecretBacked;
+ account.write_capable = true;
Ok(account)
}
@@ -406,7 +437,13 @@ pub fn resolve_local_signing_identity(
}
pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView {
- AccountSummaryView::from_account_record(&account.record, account.signer, account.is_default)
+ AccountSummaryView::from_account_runtime(
+ &account.record,
+ account.custody.signer_label(),
+ account.custody.as_str(),
+ account.write_capable,
+ account.is_default,
+ )
}
pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView {
@@ -478,11 +515,12 @@ fn snapshot_from_manager(
let is_default = default_account_id
.as_deref()
.is_some_and(|default| default == record.account_id.as_str());
- let signer = account_signer(manager, &record)?;
+ let runtime = account_runtime_facts(manager, &record)?;
accounts.push(AccountRecordView {
record,
is_default,
- signer,
+ custody: runtime.custody,
+ write_capable: runtime.write_capable,
});
}
@@ -548,15 +586,21 @@ fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) ->
}
}
-fn account_signer(
+fn account_runtime_facts(
manager: &RadrootsNostrAccountsManager,
record: &RadrootsNostrAccountRecord,
-) -> Result<&'static str, RuntimeError> {
+) -> Result<AccountRuntimeFacts, RuntimeError> {
Ok(
if manager.get_signing_identity(&record.account_id)?.is_some() {
- "local"
+ AccountRuntimeFacts {
+ custody: AccountCustody::SecretBacked,
+ write_capable: true,
+ }
} else {
- "watch_only"
+ AccountRuntimeFacts {
+ custody: AccountCustody::WatchOnly,
+ write_capable: false,
+ }
},
)
}
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -201,6 +201,26 @@ struct LoadedListingDraft {
document: ListingDraftDocument,
}
+#[derive(Debug, Clone)]
+enum ListingDraftValidationError {
+ Issue(ListingValidationIssueView),
+ MissingSellerAccount(ListingValidationIssueView),
+}
+
+impl ListingDraftValidationError {
+ fn into_issue(self) -> ListingValidationIssueView {
+ match self {
+ Self::Issue(issue) | Self::MissingSellerAccount(issue) => issue,
+ }
+ }
+}
+
+impl From<ListingValidationIssueView> for ListingDraftValidationError {
+ fn from(issue: ListingValidationIssueView) -> Self {
+ Self::Issue(issue)
+ }
+}
+
#[derive(Debug, Clone, Copy)]
pub enum ListingMutationOperation {
Publish,
@@ -508,11 +528,11 @@ pub fn validate(
)),
}
}
- Err(issue) => Ok(invalid_validation_view(
+ Err(error) => Ok(invalid_validation_view(
args.file.as_path(),
parsed.listing.d_tag.as_str(),
&context,
- issue,
+ error.into_issue(),
)),
}
}
@@ -617,7 +637,7 @@ fn summary_from_loaded(
state = "ready";
}
}
- Err(issue) => issues.push(issue),
+ Err(error) => issues.push(error.into_issue()),
}
}
Err(reason) => issues.push(ListingValidationIssueView {
@@ -820,18 +840,17 @@ fn mutate(
))
})?;
let context = validation_context(config)?;
- let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|issue| {
- if issue.field == "listing.seller_pubkey"
- && issue
- .message
- .contains("no resolved account pubkey is available")
- {
- return accounts::AccountRuntimeFailure::unresolved(format!(
- "{} ({})",
- issue.message, issue.field
- ))
- .into();
- }
+ let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| {
+ let issue = match error {
+ ListingDraftValidationError::MissingSellerAccount(issue) => {
+ return accounts::AccountRuntimeFailure::unresolved(format!(
+ "{} ({})",
+ issue.message, issue.field
+ ))
+ .into();
+ }
+ ListingDraftValidationError::Issue(issue) => issue,
+ };
RuntimeError::Config(format!(
"invalid listing draft {}: {} ({})",
args.file.display(),
@@ -984,20 +1003,22 @@ fn canonicalize_draft(
draft: &ListingDraftDocument,
contents: &str,
context: &ListingValidationContext,
-) -> Result<CanonicalListingDraft, ListingValidationIssueView> {
+) -> Result<CanonicalListingDraft, ListingDraftValidationError> {
if draft.version != 1 {
return Err(issue_for_field(
contents,
"version",
format!("unsupported listing draft version `{}`", draft.version),
- ));
+ )
+ .into());
}
if draft.kind.trim() != DRAFT_KIND {
return Err(issue_for_field(
contents,
"kind",
format!("unsupported listing draft kind `{}`", draft.kind),
- ));
+ )
+ .into());
}
let listing_id = draft.listing.d_tag.trim().to_owned();
@@ -1006,7 +1027,8 @@ fn canonicalize_draft(
contents,
"listing.d_tag",
"listing d_tag must be a 22-character base64url identifier",
- ));
+ )
+ .into());
}
let seller_pubkey = if let Some(pubkey) = non_empty(draft.listing.seller_pubkey.clone()) {
@@ -1014,10 +1036,12 @@ fn canonicalize_draft(
} else if let Some(pubkey) = context.selected_account_pubkey.clone() {
pubkey
} else {
- return Err(issue_for_field(
- contents,
- "listing.seller_pubkey",
- "missing seller_pubkey and no resolved account pubkey is available",
+ return Err(ListingDraftValidationError::MissingSellerAccount(
+ issue_for_field(
+ contents,
+ "listing.seller_pubkey",
+ "missing seller_pubkey and no resolved account pubkey is available",
+ ),
));
};
@@ -1030,14 +1054,16 @@ fn canonicalize_draft(
contents,
"listing.farm_d_tag",
"missing farm_d_tag and no selected farm config is available",
- ));
+ )
+ .into());
};
if !is_d_tag_base64url(&farm_d_tag) {
return Err(issue_for_field(
contents,
"listing.farm_d_tag",
"farm_d_tag must be a 22-character base64url identifier",
- ));
+ )
+ .into());
}
let quantity_amount = parse_decimal_field(