app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit a8d420a0465648a4ec0236eb03d9bf6ecf43f134
parent 2f08bea544c55e871833b937e3cdab7758966212
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:05:04 +0000

core(accounts): persist generated and imported accounts

- route account commands through the SQLite-backed core adapter
- reuse the durable operation journal for cross-resource writes
- restore generated and imported accounts selected but signed out
- verify restart survival without secret material in database bytes

Diffstat:
Mcore/crates/storage/src/application_adapter.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mdocs/implementation/nostr-runtime-rcld.md | 6+++---
2 files changed, 105 insertions(+), 7 deletions(-)

diff --git a/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs @@ -1,7 +1,10 @@ use std::path::Path; -use radroots_studio_application::{AppCore, AppSnapshot, RelayConfiguration}; -use radroots_studio_domain::SafeError; +use radroots_studio_application::{ + AppCore, AppSnapshot, Clock, GenerateAccountReceipt, ImportAccountReceipt, RelayConfiguration, + SecretStore, +}; +use radroots_studio_domain::{SafeError, SecretKeyInput}; use crate::Database; @@ -45,6 +48,46 @@ impl PersistentAppCore { self.core.bootstrap_from(&self.database, &self.database) } + /// Generates and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn generate_account( + &self, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account( + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn import_secret_key( + &self, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key( + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + #[must_use] pub const fn core(&self) -> &AppCore { &self.core @@ -61,11 +104,12 @@ mod tests { use std::fs; use radroots_studio_application::{ - AccountRepository, AppLifecycle, AppStateRepository, RelayConfiguration, SessionState, + AccountRepository, AppLifecycle, AppStateRepository, Clock, InMemorySecretStore, + RelayConfiguration, SecretStore, SessionState, }; use radroots_studio_domain::{ AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SafeErrorCode, - SignerKind, UnixTimestamp, + SecretKeyInput, SignerKind, UnixTimestamp, }; use tempfile::tempdir; @@ -85,6 +129,14 @@ mod tests { ) } + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(25).expect("time") + } + } + #[test] fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { let directory = tempdir().expect("directory"); @@ -130,4 +182,50 @@ mod tests { b"not a sqlite database" ); } + + #[test] + fn persisted_generate_and_import_survive_restart_without_secret_bytes() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let selected; + { + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); + adapter.bootstrap().expect("bootstrap"); + let generated = adapter + .generate_account(&secrets, &FixedClock) + .expect("generate"); + assert!( + secrets + .contains(generated.account().public_key()) + .expect("generated credential") + ); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + .to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("import"); + selected = imported.account().public_key(); + assert_eq!(adapter.core().snapshot().accounts().len(), 2); + } + + let bytes = fs::read(&path).expect("database bytes"); + assert!(!bytes.windows(5).any(|value| value == b"nsec1")); + assert!(!bytes.windows(64).any(|value| { + value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + })); + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); + let restored = reopened.bootstrap().expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(selected)); + assert_eq!(restored.session(), SessionState::SignedOut); + } } diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -330,7 +330,7 @@ available, redaction tests, Cargo checks, and inherited storage tests. ### RCLD-06: Account generation and import -Status: pending. +Status: completed. Scope: checkpoints 25 through 30. Pin and adapt the selected Nostr library; implement generation, nsec and secret-hex import, duplicate and repair @@ -501,7 +501,7 @@ handoff commit sequence. - [x] 27. Implement import secret key command. - [x] 28. Define and test duplicate import and credential-repair handling. - [x] 29. Implement add/import transaction rollback across keyring and DB. -- [ ] 30. Implement persisted generate/import using SQLite adapter. +- [x] 30. Implement persisted generate/import using SQLite adapter. ### RCLD-07 @@ -562,7 +562,7 @@ handoff commit sequence. - [x] RCLD-03: Application state machine. - [x] RCLD-04: SQLite persistence. - [x] RCLD-05: Credential boundary. -- [ ] RCLD-06: Account generation and import. +- [x] RCLD-06: Account generation and import. - [ ] RCLD-07: Account lifecycle and recovery. - [ ] RCLD-08: Relay and profile runtime. - [ ] RCLD-09: UniFFI and native build integration.