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:
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;