lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 45ccb940ca7100ed8e9a2ec87310d1e9fcd5d769
parent f9bf4528dd28855682707953f0190d11da454c0b
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:08:52 +0000

core(domain): add Nostr account summary types

- model signer capability and credential availability
- validate bounded optional local account labels
- distinguish creation and last-used timestamps
- derive deterministic public display labels without secrets

Diffstat:
Mcrates/studio_domain/src/account.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_domain/src/error.rs | 1+
Mcrates/studio_domain/src/key.rs | 5+++++
Mcrates/studio_domain/src/lib.rs | 2++
Mcrates/studio_domain/src/time.rs | 33+++++++++++++++++++++++++++++++++
5 files changed, 243 insertions(+), 0 deletions(-)

diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs @@ -1 +1,203 @@ //! Public account metadata and lifecycle values. + +use crate::time::UnixTimestamp; +use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; + +const MAX_ACCOUNT_LABEL_CHARS: usize = 80; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SignerKind { + LocalSecret, + WatchOnly, + RemoteNip46, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum KeyAvailability { + Available, + CredentialMissing, + StoreUnavailable, + NotRequired, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountLabel(String); + +impl AccountLabel { + /// Trims and validates an optional human-assigned account label value. + /// + /// # Errors + /// + /// Returns a safe metadata error when the resulting label is empty, too + /// long, or contains a control character. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let normalized = value.trim(); + if normalized.is_empty() + || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS + || normalized.chars().any(char::is_control) + { + return Err(invalid_account_metadata()); + } + Ok(Self(normalized.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct AccountCreatedAt(UnixTimestamp); + +impl AccountCreatedAt { + #[must_use] + pub const fn new(timestamp: UnixTimestamp) -> Self { + Self(timestamp) + } + + #[must_use] + pub const fn timestamp(self) -> UnixTimestamp { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountSummary { + public_key: PublicKey, + npub: Npub, + signer_kind: SignerKind, + key_availability: KeyAvailability, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, +} + +impl AccountSummary { + #[must_use] + pub const fn new( + public_key: PublicKey, + npub: Npub, + signer_kind: SignerKind, + key_availability: KeyAvailability, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, + ) -> Self { + Self { + public_key, + npub, + signer_kind, + key_availability, + label, + created_at, + last_used_at, + } + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + #[must_use] + pub fn npub(&self) -> &Npub { + &self.npub + } + + #[must_use] + pub const fn signer_kind(&self) -> SignerKind { + self.signer_kind + } + + #[must_use] + pub const fn key_availability(&self) -> KeyAvailability { + self.key_availability + } + + #[must_use] + pub fn label(&self) -> Option<&AccountLabel> { + self.label.as_ref() + } + + #[must_use] + pub const fn created_at(&self) -> AccountCreatedAt { + self.created_at + } + + #[must_use] + pub const fn last_used_at(&self) -> Option<UnixTimestamp> { + self.last_used_at + } + + #[must_use] + pub fn display_label(&self) -> String { + self.label + .as_ref() + .map_or_else(|| self.npub.short(), |label| label.as_str().to_owned()) + } +} + +const fn invalid_account_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use crate::time::UnixTimestamp; + use crate::{Npub, PublicKey}; + + use super::{AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, SignerKind}; + + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account(label: Option<AccountLabel>) -> AccountSummary { + AccountSummary::new( + PublicKey::from_bytes([7_u8; 32]), + Npub::from_encoded(NPUB.to_owned()).expect("valid npub"), + SignerKind::LocalSecret, + KeyAvailability::Available, + label, + AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), + None, + ) + } + + #[test] + fn account_label_is_trimmed_bounded_and_control_free() { + let label = AccountLabel::parse(" Farm account ").expect("valid label"); + assert_eq!(label.as_str(), "Farm account"); + + for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { + assert!(AccountLabel::parse(invalid).is_err()); + } + } + + #[test] + fn account_display_prefers_label_then_shortened_npub() { + let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label"))); + let unlabelled = account(None); + + assert_eq!(labelled.display_label(), "Farm"); + assert_eq!(unlabelled.display_label(), "npub10elfcs4…8qzvjptg"); + } + + #[test] + fn local_account_summary_contains_public_metadata_only() { + let account = account(None); + let debug = format!("{account:?}"); + + assert_eq!(account.signer_kind(), SignerKind::LocalSecret); + assert_eq!(account.key_availability(), KeyAvailability::Available); + assert!(account.label().is_none()); + assert!(account.last_used_at().is_none()); + assert_eq!(account.created_at().timestamp().as_seconds(), 10); + assert_eq!(account.public_key(), PublicKey::from_bytes([7_u8; 32])); + assert_eq!(account.npub().as_str(), NPUB); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } +} diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs @@ -6,6 +6,7 @@ use std::fmt::{self, Debug, Display, Formatter}; pub enum SafeErrorCode { InvalidPublicKey, InvalidSecretKey, + InvalidAccountMetadata, AccountAlreadyExists, AccountNotFound, KeyringUnavailable, diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -36,6 +36,11 @@ impl Npub { pub fn as_str(&self) -> &str { &self.0 } + + #[must_use] + pub fn short(&self) -> String { + format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..]) + } } impl Display for Npub { diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -7,6 +7,8 @@ pub mod profile; pub mod relay; pub mod time; +pub use account::{AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, SignerKind}; pub use error::{SafeError, SafeErrorCode, SafeMessage}; pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; pub use relay::{RelayUrl, normalize_relay_urls}; +pub use time::UnixTimestamp; diff --git a/crates/studio_domain/src/time.rs b/crates/studio_domain/src/time.rs @@ -1 +1,34 @@ //! Time values shared by account and profile records. + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct UnixTimestamp(i64); + +impl UnixTimestamp { + #[must_use] + pub const fn from_seconds(seconds: i64) -> Option<Self> { + if seconds < 0 { + None + } else { + Some(Self(seconds)) + } + } + + #[must_use] + pub const fn as_seconds(self) -> i64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::UnixTimestamp; + + #[test] + fn timestamp_rejects_negative_seconds() { + assert_eq!(UnixTimestamp::from_seconds(-1), None); + assert_eq!( + UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds), + Some(0) + ); + } +}