lib

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

commit a73eb2e5cc0f8a807fd505070773ec1d29156caa
parent 2fbb6ebb98595eed375d3606cd26202d75ab715a
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:27:43 +0000

core(storage): persist account registry and selection

- migrate public account and singleton application state tables
- preserve deterministic account ordering across restarts
- reject duplicate identities and missing selection targets
- map malformed stored metadata into safe corruption errors

Diffstat:
Mcrates/studio_application/src/ports.rs | 17++++++++++++++---
Mcrates/studio_storage/Cargo.toml | 1+
Acrates/studio_storage/migrations/V2__accounts.sql | 28++++++++++++++++++++++++++++
Acrates/studio_storage/src/accounts.rs | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/db.rs | 25++++++++++++++++++++-----
Mcrates/studio_storage/src/lib.rs | 1+
6 files changed, 420 insertions(+), 8 deletions(-)

diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs @@ -21,12 +21,19 @@ pub trait AccountRepository: Send + Sync { /// /// Returns a safe storage error when the lookup cannot complete. fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>; - /// Inserts or updates one public account record. + /// Inserts one public account record. /// /// # Errors /// /// Returns a safe storage error when the durable write fails. - fn save_account(&self, account: &AccountSummary) -> Result<(), SafeError>; + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>; + /// Updates one existing public account record. + /// + /// # Errors + /// + /// Returns a safe storage or account-not-found error when the durable + /// update cannot complete. + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>; /// Removes one public account record. /// /// # Errors @@ -137,7 +144,11 @@ mod tests { Ok(None) } - fn save_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + Ok(()) + } + + fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { Ok(()) } diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true repository.workspace = true [dependencies] +radroots-studio-application = { path = "../application" } radroots-studio-domain = { path = "../domain" } refinery.workspace = true rusqlite.workspace = true diff --git a/crates/studio_storage/migrations/V2__accounts.sql b/crates/studio_storage/migrations/V2__accounts.sql @@ -0,0 +1,28 @@ +CREATE TABLE accounts ( + pubkey TEXT PRIMARY KEY NOT NULL CHECK ( + length(pubkey) = 64 AND pubkey = lower(pubkey) + ), + npub TEXT NOT NULL CHECK (length(npub) = 63), + signer_kind TEXT NOT NULL CHECK ( + signer_kind IN ('local_secret', 'watch_only', 'remote_nip46') + ), + key_availability TEXT NOT NULL CHECK ( + key_availability IN ( + 'available', + 'credential_missing', + 'store_unavailable', + 'not_required' + ) + ), + label TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + last_used_at INTEGER CHECK (last_used_at >= 0) +); + +CREATE TABLE app_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL +); + +INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL); +UPDATE application_schema SET schema_version = 2 WHERE singleton = 1; diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs @@ -0,0 +1,356 @@ +use radroots_studio_application::{AccountRepository, AppStateRepository}; +use radroots_studio_domain::{ + AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, Npub, PublicKey, SafeError, + SafeErrorCode, SafeMessage, SignerKind, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl AccountRepository for Database { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT pubkey, npub, signer_kind, key_availability, label, created_at, \ + last_used_at FROM accounts ORDER BY created_at ASC, pubkey ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_account) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.connection() + .query_row( + "SELECT pubkey, npub, signer_kind, key_availability, label, created_at, \ + last_used_at FROM accounts WHERE pubkey = ?1", + [public_key.to_hex()], + decode_account, + ) + .optional() + .map_err(|_| storage_error()) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + let result = self.connection().execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, label, \ + created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + encoded.public_key, + encoded.npub, + encoded.signer_kind, + encoded.key_availability, + encoded.label, + encoded.created_at, + encoded.last_used_at, + ], + ); + match result { + Ok(1) => Ok(()), + Err(error) if is_constraint_violation(&error) => Err(account_exists()), + Ok(_) | Err(_) => Err(storage_error()), + } + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + match self.connection().execute( + "UPDATE accounts SET npub = ?2, signer_kind = ?3, key_availability = ?4, \ + label = ?5, created_at = ?6, last_used_at = ?7 WHERE pubkey = ?1", + params![ + encoded.public_key, + encoded.npub, + encoded.signer_kind, + encoded.key_availability, + encoded.label, + encoded.created_at, + encoded.last_used_at, + ], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(account_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM accounts WHERE pubkey = ?1", + [public_key.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +impl AppStateRepository for Database { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + let value = self + .connection() + .query_row( + "SELECT selected_pubkey FROM app_state WHERE singleton = 1", + [], + |row| row.get::<_, Option<String>>(0), + ) + .map_err(|_| corrupt_storage_error())?; + value + .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) + .transpose() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + if let Some(public_key) = public_key + && self.find_account(public_key)?.is_none() + { + return Err(account_not_found()); + } + self.connection() + .execute( + "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", + [public_key.map(PublicKey::to_hex)], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +struct EncodedAccount { + public_key: String, + npub: String, + signer_kind: &'static str, + key_availability: &'static str, + label: Option<String>, + created_at: i64, + last_used_at: Option<i64>, +} + +impl From<&AccountSummary> for EncodedAccount { + fn from(account: &AccountSummary) -> Self { + Self { + public_key: account.public_key().to_hex(), + npub: account.npub().as_str().to_owned(), + signer_kind: encode_signer_kind(account.signer_kind()), + key_availability: encode_key_availability(account.key_availability()), + label: account.label().map(|label| label.as_str().to_owned()), + created_at: account.created_at().timestamp().as_seconds(), + last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), + } + } +} + +fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { + let public_key = + PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let npub = Npub::from_encoded(row.get(1)?).map_err(|_| invalid_column(1))?; + let signer_kind = decode_signer_kind(row.get::<_, String>(2)?.as_str())?; + let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; + let label = row + .get::<_, Option<String>>(4)? + .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) + .transpose()?; + let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; + let last_used_at = row + .get::<_, Option<i64>>(6)? + .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) + .transpose()?; + + Ok(AccountSummary::new( + public_key, + npub, + signer_kind, + key_availability, + label, + AccountCreatedAt::new(created_at), + last_used_at, + )) +} + +const fn encode_signer_kind(value: SignerKind) -> &'static str { + match value { + SignerKind::LocalSecret => "local_secret", + SignerKind::WatchOnly => "watch_only", + SignerKind::RemoteNip46 => "remote_nip46", + } +} + +fn decode_signer_kind(value: &str) -> rusqlite::Result<SignerKind> { + match value { + "local_secret" => Ok(SignerKind::LocalSecret), + "watch_only" => Ok(SignerKind::WatchOnly), + "remote_nip46" => Ok(SignerKind::RemoteNip46), + _ => Err(invalid_column(2)), + } +} + +const fn encode_key_availability(value: KeyAvailability) -> &'static str { + match value { + KeyAvailability::Available => "available", + KeyAvailability::CredentialMissing => "credential_missing", + KeyAvailability::StoreUnavailable => "store_unavailable", + KeyAvailability::NotRequired => "not_required", + } +} + +fn decode_key_availability(value: &str) -> rusqlite::Result<KeyAvailability> { + match value { + "available" => Ok(KeyAvailability::Available), + "credential_missing" => Ok(KeyAvailability::CredentialMissing), + "store_unavailable" => Ok(KeyAvailability::StoreUnavailable), + "not_required" => Ok(KeyAvailability::NotRequired), + _ => Err(invalid_column(3)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "public account metadata".to_owned(), + rusqlite::types::Type::Text, + ) +} + +fn is_constraint_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::ConstraintViolation, + .. + }, + _ + ) + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +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 std::fs; + + use radroots_studio_application::{AccountRepository, AppStateRepository}; + use radroots_studio_domain::{ + AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, Npub, PublicKey, + SafeErrorCode, SignerKind, UnixTimestamp, + }; + use tempfile::tempdir; + + use crate::Database; + + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account(key_byte: u8, created_at: i64) -> AccountSummary { + AccountSummary::new( + PublicKey::from_bytes([key_byte; 32]), + Npub::from_encoded(NPUB.to_owned()).expect("valid npub"), + SignerKind::LocalSecret, + KeyAvailability::Available, + Some(AccountLabel::parse("Farm account").expect("valid label")), + AccountCreatedAt::new( + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + ), + None, + ) + } + + #[test] + fn accounts_insert_list_update_and_reject_duplicates() { + let database = Database::in_memory().expect("database"); + let first = account(1, 20); + let second = account(2, 10); + + database.insert_account(&first).expect("insert first"); + database.insert_account(&second).expect("insert second"); + let duplicate = database.insert_account(&first).expect_err("duplicate"); + + assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!( + database.list_accounts().expect("list"), + vec![second, first.clone()] + ); + assert_eq!( + database.find_account(first.public_key()).expect("find"), + Some(first) + ); + } + + #[test] + fn accounts_and_selection_survive_restart_without_secret_text() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let account = account(3, 30); + + { + let database = Database::open(&path).expect("database"); + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + } + let reopened = Database::open(&path).expect("reopen"); + + assert_eq!( + reopened.list_accounts().expect("list"), + vec![account.clone()] + ); + assert_eq!( + reopened.load_selected_account().expect("selection"), + Some(account.public_key()) + ); + let bytes = fs::read(path).expect("database bytes"); + assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); + } + + #[test] + fn selection_requires_an_existing_account_and_clears_on_delete() { + let database = Database::in_memory().expect("database"); + let account = account(4, 40); + + let missing = database + .save_selected_account(Some(account.public_key())) + .expect_err("missing account"); + assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); + + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + database + .remove_account(account.public_key()) + .expect("remove"); + + assert_eq!(database.load_selected_account().expect("selection"), None); + } +} diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -6,6 +6,9 @@ use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; +#[cfg(test)] +const LATEST_SCHEMA_VERSION: u32 = 2; + mod migrations { use super::embed_migrations; @@ -118,14 +121,20 @@ mod tests { use tempfile::tempdir; - use super::Database; + use super::{Database, LATEST_SCHEMA_VERSION}; #[test] fn migration_opens_fresh_memory_database_once() { let database = Database::in_memory().expect("open memory database"); - assert_eq!(database.schema_version().expect("schema version"), 1); - assert_eq!(database.schema_version().expect("repeat schema version"), 1); + assert_eq!( + database.schema_version().expect("schema version"), + LATEST_SCHEMA_VERSION + ); + assert_eq!( + database.schema_version().expect("repeat schema version"), + LATEST_SCHEMA_VERSION + ); } #[test] @@ -135,10 +144,16 @@ mod tests { { let database = Database::open(&path).expect("open file database"); - assert_eq!(database.schema_version().expect("schema version"), 1); + assert_eq!( + database.schema_version().expect("schema version"), + LATEST_SCHEMA_VERSION + ); } let reopened = Database::open(&path).expect("reopen file database"); - assert_eq!(reopened.schema_version().expect("schema version"), 1); + assert_eq!( + reopened.schema_version().expect("schema version"), + LATEST_SCHEMA_VERSION + ); assert!(fs::metadata(path).expect("database metadata").len() > 0); } diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -1,5 +1,6 @@ #![doc = "Radroots Studio persistence adapters."] +pub mod accounts; pub mod db; pub use db::Database;