lib

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

commit 9b329721eae701e08c6056b9e0f3954306322d05
parent bc61676668cfb1b684be39542009c3f96d6e4e79
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 19:45:31 +0000

identity: move account records from the Nostr accounts package

- define portable account Record and Status values under identity account
- enforce derived account identity and monotonic timestamp invariants
- leave store snapshots and host orchestration in radroots_nostr_accounts
- verify feature, WASM, strict lint, serde, docs, and boundary gates

Diffstat:
Mcrates/identity/src/account.rs | 284++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/identity/src/error.rs | 19+++++++++++++++++++
Mcrates/nostr_accounts/Cargo.toml | 5++++-
Mcrates/nostr_accounts/src/lib.rs | 5+----
Mcrates/nostr_accounts/src/model.rs | 44+++-----------------------------------------
5 files changed, 309 insertions(+), 48 deletions(-)

diff --git a/crates/identity/src/account.rs b/crates/identity/src/account.rs @@ -1,6 +1,8 @@ -//! Public account value types. +//! Public, transport-neutral account value types. -use crate::{IdentityId, key::define_identifier}; +use alloc::string::String; + +use crate::{Error, IdentityId, PublicIdentity, key::define_identifier}; define_identifier! { /// A canonical public account identifier. @@ -13,6 +15,12 @@ impl AccountId { pub const fn from_identity_id(identity_id: IdentityId) -> Self { Self::from_validated_bytes(identity_id.into_bytes()) } + + /// Derives the account identifier from a public identity. + #[must_use] + pub const fn from_public_identity(public_identity: &PublicIdentity) -> Self { + Self::from_identity_id(public_identity.id()) + } } impl From<IdentityId> for AccountId { @@ -21,9 +29,186 @@ impl From<IdentityId> for AccountId { } } +impl From<&PublicIdentity> for AccountId { + fn from(value: &PublicIdentity) -> Self { + Self::from_public_identity(value) + } +} + +/// A portable public account record. +/// +/// The account identifier is always derived from `public_identity`. Secret +/// material, persistence location, account selection, and signer state are +/// intentionally absent. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + account_id: AccountId, + public_identity: PublicIdentity, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, +} + +impl Record { + /// Creates a public account record at the supplied Unix timestamp. + #[must_use] + pub fn new( + public_identity: PublicIdentity, + label: Option<String>, + created_at_unix: u64, + ) -> Self { + Self { + account_id: AccountId::from_public_identity(&public_identity), + public_identity, + label, + created_at_unix, + updated_at_unix: created_at_unix, + } + } + + /// Validates an account record assembled from separately decoded parts. + pub fn try_from_parts( + account_id: AccountId, + public_identity: PublicIdentity, + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, + ) -> Result<Self, Error> { + if account_id != AccountId::from_public_identity(&public_identity) { + return Err(Error::AccountIdMismatch); + } + if updated_at_unix < created_at_unix { + return Err(Error::AccountUpdatedBeforeCreated { + created_at_unix, + updated_at_unix, + }); + } + Ok(Self { + account_id, + public_identity, + label, + created_at_unix, + updated_at_unix, + }) + } + + /// Returns the canonical account identifier. + #[must_use] + pub const fn id(&self) -> AccountId { + self.account_id + } + + /// Borrows the public identity represented by this account. + #[must_use] + pub const fn public_identity(&self) -> &PublicIdentity { + &self.public_identity + } + + /// Borrows the optional host-facing label. + #[must_use] + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + + /// Replaces the optional host-facing label without changing identity. + pub fn set_label(&mut self, label: Option<String>) { + self.label = label; + } + + /// Returns the record creation timestamp as Unix seconds. + #[must_use] + pub const fn created_at_unix(&self) -> u64 { + self.created_at_unix + } + + /// Returns the latest record update timestamp as Unix seconds. + #[must_use] + pub const fn updated_at_unix(&self) -> u64 { + self.updated_at_unix + } + + /// Advances the record update timestamp without permitting time reversal. + pub fn touch_updated(&mut self, updated_at_unix: u64) -> Result<(), Error> { + if updated_at_unix < self.updated_at_unix { + return Err(Error::AccountUpdateRegressed { + current_updated_at_unix: self.updated_at_unix, + proposed_updated_at_unix: updated_at_unix, + }); + } + self.updated_at_unix = updated_at_unix; + Ok(()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Record { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct RecordRepr { + account_id: AccountId, + public_identity: PublicIdentity, + #[serde(default)] + label: Option<String>, + created_at_unix: u64, + updated_at_unix: u64, + } + + let value = RecordRepr::deserialize(deserializer)?; + Self::try_from_parts( + value.account_id, + value.public_identity, + value.label, + value.created_at_unix, + value.updated_at_unix, + ) + .map_err(serde::de::Error::custom) + } +} + +/// Publicly observable account readiness without secret or signer details. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Status { + /// No account has been configured by the composing host. + #[default] + NotConfigured, + /// The account can be observed but has no available signing capability. + PublicOnly { account: Record }, + /// The composing host reports that the account can sign. + Ready { account: Record }, +} + +impl Status { + /// Borrows the configured account, when present. + #[must_use] + pub const fn account(&self) -> Option<&Record> { + match self { + Self::NotConfigured => None, + Self::PublicOnly { account } | Self::Ready { account } => Some(account), + } + } + + /// Reports whether the composing host marked the account ready to sign. + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self, Self::Ready { .. }) + } +} + #[cfg(test)] mod tests { + #[cfg(feature = "serde")] use alloc::format; + use alloc::string::ToString; use core::str::FromStr; use super::*; @@ -64,4 +249,99 @@ mod tests { ); assert!(serde_json::from_str::<AccountId>("\"not-an-account\"").is_err()); } + + fn public_identity(value: &str) -> PublicIdentity { + PublicIdentity::new(crate::PublicKey::from_hex(value).expect("public key")) + } + + #[test] + fn records_derive_identity_and_enforce_monotonic_timestamps() { + let identity = public_identity(ALICE); + let mut record = Record::new(identity.clone(), Some("primary".to_string()), 10); + + assert_eq!(record.id(), AccountId::from_public_identity(&identity)); + assert_eq!(record.public_identity(), &identity); + assert_eq!(record.label(), Some("primary")); + assert_eq!(record.created_at_unix(), 10); + assert_eq!(record.updated_at_unix(), 10); + assert!(matches!( + record.touch_updated(9), + Err(Error::AccountUpdateRegressed { + current_updated_at_unix: 10, + proposed_updated_at_unix: 9, + }) + )); + assert_eq!(record.updated_at_unix(), 10); + + record.touch_updated(12).expect("advance timestamp"); + assert!(matches!( + record.touch_updated(11), + Err(Error::AccountUpdateRegressed { + current_updated_at_unix: 12, + proposed_updated_at_unix: 11, + }) + )); + record.set_label(None); + assert_eq!(record.updated_at_unix(), 12); + assert_eq!(record.label(), None); + + let wrong_id = AccountId::from_identity_id(IdentityId::from_hex(BOB).unwrap()); + assert!(matches!( + Record::try_from_parts(wrong_id, identity, None, 10, 10), + Err(Error::AccountIdMismatch) + )); + } + + #[test] + fn status_exposes_only_public_account_readiness() { + let record = Record::new(public_identity(ALICE), None, 10); + let not_configured = Status::default(); + let public_only = Status::PublicOnly { + account: record.clone(), + }; + let ready = Status::Ready { + account: record.clone(), + }; + + assert!(not_configured.account().is_none()); + assert_eq!(public_only.account(), Some(&record)); + assert!(!public_only.is_ready()); + assert_eq!(ready.account(), Some(&record)); + assert!(ready.is_ready()); + } + + #[cfg(feature = "serde")] + #[test] + fn records_and_status_serde_revalidate_public_identity_and_timestamps() { + let record = Record::new(public_identity(ALICE), Some("primary".to_string()), 10); + let status = Status::Ready { + account: record.clone(), + }; + let encoded_record = serde_json::to_string(&record).expect("serialize record"); + let encoded_status = serde_json::to_string(&status).expect("serialize status"); + + assert_eq!( + serde_json::from_str::<Record>(&encoded_record).expect("deserialize record"), + record + ); + assert_eq!( + serde_json::from_str::<Status>(&encoded_status).expect("deserialize status"), + status + ); + assert!( + serde_json::from_str::<Record>(&encoded_record.replace( + &format!("\"account_id\":\"{ALICE}\""), + &format!("\"account_id\":\"{BOB}\""), + )) + .is_err() + ); + assert!( + serde_json::from_str::<Record>( + &encoded_record.replace("\"created_at_unix\":10", "\"created_at_unix\":11") + ) + .is_err() + ); + assert!(!encoded_record.contains("secret")); + assert!(!encoded_status.contains("signer")); + } } diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -21,6 +21,25 @@ pub enum Error { #[error("public identity identifier does not match its public key")] IdentityIdMismatch, + #[error("account identifier does not match its public identity")] + AccountIdMismatch, + + #[error( + "account updated timestamp {updated_at_unix} precedes created timestamp {created_at_unix}" + )] + AccountUpdatedBeforeCreated { + created_at_unix: u64, + updated_at_unix: u64, + }, + + #[error( + "account update timestamp {proposed_updated_at_unix} precedes current timestamp {current_updated_at_unix}" + )] + AccountUpdateRegressed { + current_updated_at_unix: u64, + proposed_updated_at_unix: u64, + }, + #[error("username length must be between {min} and {max} ASCII bytes, but was {actual}")] InvalidUsernameLength { min: usize, diff --git a/crates/nostr_accounts/Cargo.toml b/crates/nostr_accounts/Cargo.toml @@ -29,7 +29,10 @@ os-keyring = ["std", "radroots_secret_vault/os-keyring"] nostrdb-bridge = ["std", "dep:radroots_nostrdb"] [dependencies] -radroots_identity = { workspace = true, optional = true, default-features = false, features = ["std"] } +radroots_identity = { workspace = true, optional = true, default-features = false, features = [ + "std", + "serde", +] } radroots_nostr_signer = { workspace = true, optional = true } radroots_nostrdb = { workspace = true, optional = true, default-features = false, features = [ "nostrdb", diff --git a/crates/nostr_accounts/src/lib.rs b/crates/nostr_accounts/src/lib.rs @@ -18,10 +18,7 @@ pub mod prelude { #[cfg(feature = "std")] pub use crate::manager::RadrootsNostrAccountsManager; #[cfg(feature = "std")] - pub use crate::model::{ - RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountRecord, - RadrootsNostrAccountStatus, RadrootsNostrAccountStoreState, - }; + pub use crate::model::{RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountStoreState}; #[cfg(feature = "nostrdb-bridge")] pub use crate::nostrdb_bridge::radroots_nostr_accounts_register_default_secret_with_nostrdb; #[cfg(feature = "std")] diff --git a/crates/nostr_accounts/src/model.rs b/crates/nostr_accounts/src/model.rs @@ -1,30 +1,13 @@ -use radroots_identity::{RadrootsIdentityId, RadrootsIdentityPublic}; +use radroots_identity::{AccountId, account::Record}; use serde::{Deserialize, Serialize}; pub const RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RadrootsNostrAccountRecord { - pub account_id: RadrootsIdentityId, - pub public_identity: RadrootsIdentityPublic, - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - pub created_at_unix: u64, - pub updated_at_unix: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrAccountStoreState { pub version: u32, - pub default_account_id: Option<RadrootsIdentityId>, - pub accounts: Vec<RadrootsNostrAccountRecord>, -} - -#[derive(Debug, Clone)] -pub enum RadrootsNostrAccountStatus { - NotConfigured, - PublicOnly { account: RadrootsNostrAccountRecord }, - Ready { account: RadrootsNostrAccountRecord }, + pub default_account_id: Option<AccountId>, + pub accounts: Vec<Record>, } impl Default for RadrootsNostrAccountStoreState { @@ -36,24 +19,3 @@ impl Default for RadrootsNostrAccountStoreState { } } } - -impl RadrootsNostrAccountRecord { - pub fn new( - public_identity: RadrootsIdentityPublic, - label: Option<String>, - created_at_unix: u64, - ) -> Self { - let account_id = public_identity.id.clone(); - Self { - account_id, - public_identity, - label, - created_at_unix, - updated_at_unix: created_at_unix, - } - } - - pub fn touch_updated(&mut self, updated_at_unix: u64) { - self.updated_at_unix = updated_at_unix; - } -}