commit bf7d07c0de67afee5101697b8a81116049bae832
parent 1bac8ff9dfce7eae8afb2edaef630bdf26d89cb7
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 18:57:05 +0000
core(accounts): implement generated account command
- generate and persist one local Nostr account atomically in memory
- select new accounts without implicitly activating a session
- return generated nsec only through a one-time receipt
- publish public registry state after credential and metadata writes
Diffstat:
6 files changed, 223 insertions(+), 2 deletions(-)
diff --git a/core/Cargo.lock b/core/Cargo.lock
@@ -1199,6 +1199,7 @@ name = "radroots-studio-application"
version = "0.1.0-alpha.0"
dependencies = [
"radroots-studio-domain",
+ "radroots-studio-nostr",
"secrecy",
]
diff --git a/core/crates/application/Cargo.toml b/core/crates/application/Cargo.toml
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies]
radroots-studio-domain = { path = "../domain" }
+radroots-studio-nostr = { path = "../nostr" }
secrecy.workspace = true
[lints]
diff --git a/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs
@@ -0,0 +1,214 @@
+use std::sync::{Mutex, MutexGuard};
+
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountSummary, KeyAvailability, Nsec, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage, SignerKind,
+};
+use radroots_studio_nostr::generate_local_keypair;
+
+use crate::{AccountRepository, AppCore, AppStateRepository, Clock, SecretStore, StateTransition};
+
+pub struct GenerateAccountReceipt {
+ account: AccountSummary,
+ generated_nsec: Nsec,
+}
+
+impl GenerateAccountReceipt {
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+
+ #[must_use]
+ pub const fn generated_nsec(&self) -> &Nsec {
+ &self.generated_nsec
+ }
+}
+
+impl AppCore {
+ /// Generates, stores, and selects one local Nostr account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe key, credential, persistence, or application-state error.
+ pub fn generate_account(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<GenerateAccountReceipt, SafeError> {
+ let generated = generate_local_keypair()?;
+ let (public_key, npub, secret, nsec) = generated.into_parts();
+ let account = AccountSummary::new(
+ public_key,
+ npub,
+ SignerKind::LocalSecret,
+ KeyAvailability::Available,
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ );
+ secrets.put(public_key, secret)?;
+ accounts.insert_account(&account)?;
+ app_state.save_selected_account(Some(public_key))?;
+ let registry = accounts.list_accounts()?;
+ self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: registry,
+ selected: Some(public_key),
+ })?;
+ Ok(GenerateAccountReceipt {
+ account,
+ generated_nsec: nsec,
+ })
+ }
+}
+
+#[derive(Default)]
+pub struct InMemoryAccountRepository {
+ state: Mutex<InMemoryAccountState>,
+}
+
+#[derive(Default)]
+struct InMemoryAccountState {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+}
+
+impl InMemoryAccountRepository {
+ fn state(&self) -> MutexGuard<'_, InMemoryAccountState> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+impl AccountRepository for InMemoryAccountRepository {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ Ok(self.state().accounts.clone())
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ Ok(self
+ .state()
+ .accounts
+ .iter()
+ .find(|account| account.public_key() == public_key)
+ .cloned())
+ }
+
+ fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let mut state = self.state();
+ if state
+ .accounts
+ .iter()
+ .any(|saved| saved.public_key() == account.public_key())
+ {
+ return Err(account_exists());
+ }
+ state.accounts.push(account.clone());
+ state
+ .accounts
+ .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key()));
+ Ok(())
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let mut state = self.state();
+ let saved = state
+ .accounts
+ .iter_mut()
+ .find(|saved| saved.public_key() == account.public_key())
+ .ok_or_else(account_not_found)?;
+ *saved = account.clone();
+ Ok(())
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ let mut state = self.state();
+ state
+ .accounts
+ .retain(|account| account.public_key() != public_key);
+ if state.selected == Some(public_key) {
+ state.selected = None;
+ }
+ Ok(())
+ }
+}
+
+impl AppStateRepository for InMemoryAccountRepository {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ Ok(self.state().selected)
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ let mut state = self.state();
+ if public_key.is_some_and(|key| {
+ !state
+ .accounts
+ .iter()
+ .any(|account| account.public_key() == key)
+ }) {
+ return Err(account_not_found());
+ }
+ state.selected = public_key;
+ Ok(())
+ }
+}
+
+const fn account_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account is already saved."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::UnixTimestamp;
+
+ use super::InMemoryAccountRepository;
+ use crate::{
+ AppCore, AppStateRepository, Clock, InMemorySecretStore, RelayConfiguration, SecretStore,
+ SessionState,
+ };
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(10).expect("time")
+ }
+ }
+
+ #[test]
+ fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ core.bootstrap().expect("bootstrap");
+
+ let receipt = core
+ .generate_account(&accounts, &accounts, &secrets, &FixedClock)
+ .expect("generate");
+ let public_key = receipt.account().public_key();
+ assert_eq!(public_key.to_hex().len(), 64);
+ assert!(secrets.contains(public_key).expect("credential"));
+ assert_eq!(
+ accounts.load_selected_account().expect("selection"),
+ Some(public_key)
+ );
+ assert_eq!(core.snapshot().selected_account(), Some(public_key));
+ assert_eq!(core.snapshot().session(), SessionState::SignedOut);
+ assert!(core.snapshot().active_account().is_none());
+ assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63);
+ assert!(!format!("{:?}", core.snapshot()).contains("nsec1"));
+ }
+}
diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs
@@ -114,7 +114,10 @@ impl AppCore {
self.lock_state().observers.remove(&handle).is_some()
}
- fn apply_transition(&self, transition: StateTransition) -> Result<AppSnapshot, SafeError> {
+ pub(crate) fn apply_transition(
+ &self,
+ transition: StateTransition,
+ ) -> Result<AppSnapshot, SafeError> {
let (snapshot, observers) = {
let mut state = self.lock_state();
let previous_revision = state.state_machine.snapshot().revision();
diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs
@@ -1,11 +1,13 @@
#![doc = "Radroots Studio application runtime."]
+pub mod accounts;
pub mod app_core;
pub mod ports;
pub mod secrets;
pub mod snapshot;
pub mod state_machine;
+pub use accounts::{GenerateAccountReceipt, InMemoryAccountRepository};
pub use app_core::{AppCore, AppObserver, ObserverHandle};
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -497,7 +497,7 @@ handoff commit sequence.
### RCLD-06
- [x] 25. Pin Nostr dependency and implement key generation/derivation adapter.
-- [ ] 26. Implement generate account command with in-memory storage.
+- [x] 26. Implement generate account command with in-memory storage.
- [ ] 27. Implement import secret key command.
- [ ] 28. Define and test duplicate import and credential-repair handling.
- [ ] 29. Implement add/import transaction rollback across keyring and DB.