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:
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)
+ );
+ }
+}