commit a4d7deebec3e2ce2c1daa455de6d79857839aed0
parent c709d03f0ede14aacd67b6856d146daebe5b035e
Author: triesap <tyson@radroots.org>
Date: Sun, 9 Aug 2026 17:42:10 +0000
domain: own the Studio domain crate
- copy the exact locked Studio domain source into HarvestCircle core
- register the domain crate as a local workspace dependency
- retain identity as an immutable canonical Radroots Git dependency
- refresh the lockfile after the source-boundary change
Diffstat:
10 files changed, 1593 insertions(+), 8 deletions(-)
diff --git a/core/Cargo.lock b/core/Cargo.lock
@@ -1945,7 +1945,7 @@ name = "radroots_studio_application"
version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"secrecy",
"tokio",
]
@@ -1953,6 +1953,17 @@ dependencies = [
[[package]]
name = "radroots_studio_domain"
version = "0.1.0-alpha"
+dependencies = [
+ "bech32",
+ "radroots_identity",
+ "secrecy",
+ "url",
+ "zeroize",
+]
+
+[[package]]
+name = "radroots_studio_domain"
+version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
"bech32",
@@ -1970,7 +1981,7 @@ dependencies = [
"directories",
"quote",
"radroots_studio_application",
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_nostr",
"radroots_studio_runtime",
"radroots_studio_storage",
@@ -1989,7 +2000,7 @@ dependencies = [
"nostr-sdk 0.44.0",
"radroots_identity",
"radroots_studio_application",
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_transport",
"radroots_transport_nostr",
"tokio",
@@ -2008,7 +2019,7 @@ version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
"radroots_studio_application",
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_nostr",
"radroots_studio_storage",
"tokio",
@@ -2020,7 +2031,7 @@ name = "radroots_studio_source_lock"
version = "0.1.0-alpha"
dependencies = [
"radroots_studio_application",
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha",
"radroots_studio_ffi",
"radroots_studio_nostr",
"radroots_studio_preferences",
@@ -2038,7 +2049,7 @@ dependencies = [
"hmac",
"keyring",
"radroots_studio_application",
- "radroots_studio_domain",
+ "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"refinery",
"rusqlite",
"rustix",
diff --git a/core/Cargo.toml b/core/Cargo.toml
@@ -1,5 +1,9 @@
[workspace]
-members = ["crates/source_lock", "crates/studio_preferences"]
+members = [
+ "crates/source_lock",
+ "crates/studio_domain",
+ "crates/studio_preferences",
+]
resolver = "3"
[workspace.package]
@@ -20,9 +24,10 @@ pedantic = "deny"
[workspace.dependencies]
radroots_studio_application = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
-radroots_studio_domain = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
+radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" }
radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
radroots_studio_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" }
radroots_studio_runtime = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
radroots_studio_storage = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
+radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false }
diff --git a/core/crates/studio_domain/Cargo.toml b/core/crates/studio_domain/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "radroots_studio_domain"
+description = "Private domain model for Radroots Studio"
+version = "0.1.0-alpha"
+edition.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+license = "GPL-3.0-only"
+repository.workspace = true
+homepage.workspace = true
+publish = false
+include = ["src/**", "Cargo.toml"]
+
+[dependencies]
+bech32 = "=0.11.1"
+radroots_identity.workspace = true
+secrecy = "=0.10.3"
+url = "=2.5.8"
+zeroize = "=1.9.0"
+
+[lints]
+workspace = true
diff --git a/core/crates/studio_domain/src/account.rs b/core/crates/studio_domain/src/account.rs
@@ -0,0 +1,430 @@
+//! Public account metadata and lifecycle values.
+
+use crate::time::UnixTimestamp;
+use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage};
+
+const MAX_ACCOUNT_LABEL_CHARS: usize = 80;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountIdentity {
+ public_key: PublicKey,
+ npub: Npub,
+}
+
+impl AccountIdentity {
+ /// Constructs one canonical Nostr account identity and derives its npub.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error if canonical NIP-19 encoding fails.
+ pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
+ Ok(Self {
+ public_key,
+ npub: Npub::derive(public_key)?,
+ })
+ }
+
+ /// Reconstitutes persisted identity only when its public forms agree.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error for a mismatched or malformed npub.
+ pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> {
+ Ok(Self {
+ public_key,
+ npub: Npub::verify(public_key, npub)?,
+ })
+ }
+
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+
+ #[must_use]
+ pub const fn npub(&self) -> &Npub {
+ &self.npub
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct LocalSignerBinding {
+ account: PublicKey,
+ availability: BindingAvailability,
+}
+
+impl LocalSignerBinding {
+ #[must_use]
+ pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self {
+ Self {
+ account,
+ availability,
+ }
+ }
+
+ #[must_use]
+ pub const fn account(self) -> PublicKey {
+ self.account
+ }
+
+ #[must_use]
+ pub const fn availability(self) -> BindingAvailability {
+ self.availability
+ }
+
+ #[must_use]
+ pub const fn repair_action(self) -> Option<BindingRepairAction> {
+ match self.availability {
+ BindingAvailability::Available => None,
+ BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential),
+ BindingAvailability::StoreUnavailable => {
+ Some(BindingRepairAction::RetryCredentialStore)
+ }
+ }
+ }
+
+ /// Records a missing credential after a successful store lookup.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless the binding was previously available.
+ pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> {
+ self.transition(
+ BindingAvailability::Available,
+ BindingAvailability::CredentialMissing,
+ )
+ }
+
+ pub fn mark_store_unavailable(&mut self) {
+ self.availability = BindingAvailability::StoreUnavailable;
+ }
+
+ /// Completes an explicit credential repair.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless a credential was missing.
+ pub fn repair_credential(&mut self) -> Result<(), SafeError> {
+ self.transition(
+ BindingAvailability::CredentialMissing,
+ BindingAvailability::Available,
+ )
+ }
+
+ /// Resolves a recovered store lookup to its observed credential state.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless the credential store was unavailable.
+ pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> {
+ if self.availability != BindingAvailability::StoreUnavailable {
+ return Err(invalid_account_metadata());
+ }
+ self.availability = if credential_present {
+ BindingAvailability::Available
+ } else {
+ BindingAvailability::CredentialMissing
+ };
+ Ok(())
+ }
+
+ fn transition(
+ &mut self,
+ expected: BindingAvailability,
+ next: BindingAvailability,
+ ) -> Result<(), SafeError> {
+ if self.availability != expected {
+ return Err(invalid_account_metadata());
+ }
+ self.availability = next;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BindingAvailability {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BindingRepairAction {
+ ImportCredential,
+ RetryCredentialStore,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountLabel(String);
+
+impl AccountLabel {
+ /// Trims and validates an optional human-assigned account label value.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe metadata error when the resulting label is empty, too
+ /// long, or contains a control character.
+ pub fn parse(value: &str) -> Result<Self, SafeError> {
+ let normalized = value.trim();
+ if normalized.is_empty()
+ || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS
+ || normalized.chars().any(char::is_control)
+ {
+ return Err(invalid_account_metadata());
+ }
+ Ok(Self(normalized.to_owned()))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct AccountCreatedAt(UnixTimestamp);
+
+impl AccountCreatedAt {
+ #[must_use]
+ pub const fn new(timestamp: UnixTimestamp) -> Self {
+ Self(timestamp)
+ }
+
+ #[must_use]
+ pub const fn timestamp(self) -> UnixTimestamp {
+ self.0
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountSummary {
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ label: Option<AccountLabel>,
+ created_at: AccountCreatedAt,
+ last_used_at: Option<UnixTimestamp>,
+}
+
+impl AccountSummary {
+ /// Creates an account summary whose identity and signer binding refer to the same account.
+ ///
+ /// # Errors
+ ///
+ /// Returns an invalid-account-metadata error when the signer binding belongs to a different
+ /// public key.
+ pub fn new(
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ label: Option<AccountLabel>,
+ created_at: AccountCreatedAt,
+ last_used_at: Option<UnixTimestamp>,
+ ) -> Result<Self, SafeError> {
+ if identity.public_key() != signer.account() {
+ return Err(invalid_account_metadata());
+ }
+ Ok(Self {
+ identity,
+ signer,
+ label,
+ created_at,
+ last_used_at,
+ })
+ }
+
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.identity.public_key()
+ }
+
+ #[must_use]
+ pub fn npub(&self) -> &Npub {
+ self.identity.npub()
+ }
+
+ #[must_use]
+ pub const fn signer(&self) -> LocalSignerBinding {
+ self.signer
+ }
+
+ #[must_use]
+ pub fn label(&self) -> Option<&AccountLabel> {
+ self.label.as_ref()
+ }
+
+ #[must_use]
+ pub const fn created_at(&self) -> AccountCreatedAt {
+ self.created_at
+ }
+
+ #[must_use]
+ pub const fn last_used_at(&self) -> Option<UnixTimestamp> {
+ self.last_used_at
+ }
+
+ #[must_use]
+ pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self {
+ Self {
+ identity: self.identity.clone(),
+ signer: LocalSignerBinding::new(self.public_key(), availability),
+ label: self.label.clone(),
+ created_at: self.created_at,
+ last_used_at: self.last_used_at,
+ }
+ }
+
+ #[must_use]
+ pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
+ Self {
+ identity: self.identity.clone(),
+ signer: self.signer,
+ label: self.label.clone(),
+ created_at: self.created_at,
+ last_used_at: Some(last_used_at),
+ }
+ }
+
+ #[must_use]
+ pub fn display_label(&self) -> String {
+ self.label
+ .as_ref()
+ .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned())
+ }
+}
+
+const fn invalid_account_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidAccountMetadata,
+ SafeMessage::new("The account metadata is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::PublicKey;
+ use crate::time::UnixTimestamp;
+
+ use super::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ BindingRepairAction, LocalSignerBinding,
+ };
+
+ const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7";
+ const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
+
+ fn public_key() -> PublicKey {
+ PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
+ }
+
+ fn account(label: Option<AccountLabel>) -> AccountSummary {
+ let public_key = public_key();
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ label,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ #[test]
+ fn account_label_is_trimmed_bounded_and_control_free() {
+ let label = AccountLabel::parse(" Farm account ").expect("valid label");
+ assert_eq!(label.as_str(), "Farm account");
+
+ for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] {
+ assert!(AccountLabel::parse(invalid).is_err());
+ }
+ }
+
+ #[test]
+ fn account_display_prefers_label_then_shortened_npub() {
+ let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label")));
+ let unlabelled = account(None);
+
+ assert_eq!(labelled.display_label(), "Farm");
+ assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7");
+ }
+
+ #[test]
+ fn local_account_summary_contains_public_metadata_only() {
+ let account = account(None);
+ let debug = format!("{account:?}");
+
+ assert_eq!(
+ account.signer().availability(),
+ BindingAvailability::Available
+ );
+ assert!(account.label().is_none());
+ assert!(account.last_used_at().is_none());
+ assert_eq!(account.created_at().timestamp().as_seconds(), 10);
+ assert_eq!(account.public_key(), public_key());
+ assert_eq!(account.npub().as_str(), DERIVED_NPUB);
+ assert!(!debug.contains("nsec1"));
+ assert!(!debug.contains(&"11".repeat(32)));
+ }
+
+ #[test]
+ fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() {
+ let public_key = public_key();
+ let identity = AccountIdentity::derive(public_key).expect("identity");
+ assert_eq!(identity.public_key(), public_key);
+ assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
+ assert_eq!(
+ AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"),
+ identity
+ );
+ assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err());
+ assert!(
+ AccountIdentity::verify(
+ PublicKey::from_hex(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ )
+ .expect("second public key"),
+ MISMATCHED_NPUB.to_owned()
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn local_signer_binding_carries_only_canonical_account_identity() {
+ let public_key = public_key();
+ let identity = AccountIdentity::derive(public_key).expect("identity");
+ let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
+
+ assert_eq!(binding.account(), identity.public_key());
+ assert!(!format!("{binding:?}").contains("nsec1"));
+ }
+
+ #[test]
+ fn local_binding_repair_transitions_are_typed_and_fail_closed() {
+ let public_key = public_key();
+ let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
+ assert_eq!(binding.repair_action(), None);
+ assert!(binding.repair_credential().is_err());
+
+ binding
+ .mark_credential_missing()
+ .expect("missing credential");
+ assert_eq!(
+ binding.repair_action(),
+ Some(BindingRepairAction::ImportCredential)
+ );
+ binding.repair_credential().expect("repair");
+
+ binding.mark_store_unavailable();
+ assert_eq!(
+ binding.repair_action(),
+ Some(BindingRepairAction::RetryCredentialStore)
+ );
+ binding
+ .resolve_store_recovery(false)
+ .expect("store recovery");
+ assert_eq!(
+ binding.availability(),
+ BindingAvailability::CredentialMissing
+ );
+ assert!(binding.resolve_store_recovery(true).is_err());
+ }
+}
diff --git a/core/crates/studio_domain/src/error.rs b/core/crates/studio_domain/src/error.rs
@@ -0,0 +1,113 @@
+use std::error::Error;
+use std::fmt::{self, Debug, Display, Formatter};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SafeErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidAccountMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ AccountAlreadyExists,
+ AccountNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ StorageQuarantined,
+ StorageBackupInvalid,
+ UnsupportedSchemaVersion,
+ RepairUnauthorized,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+}
+
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub struct SafeMessage(&'static str);
+
+impl SafeMessage {
+ #[must_use]
+ pub const fn new(message: &'static str) -> Self {
+ Self(message)
+ }
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ self.0
+ }
+}
+
+impl Debug for SafeMessage {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.debug_tuple("SafeMessage").field(&self.0).finish()
+ }
+}
+
+impl Display for SafeMessage {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.0)
+ }
+}
+
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub struct SafeError {
+ code: SafeErrorCode,
+ message: SafeMessage,
+}
+
+impl SafeError {
+ #[must_use]
+ pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self {
+ Self { code, message }
+ }
+
+ #[must_use]
+ pub const fn code(self) -> SafeErrorCode {
+ self.code
+ }
+
+ #[must_use]
+ pub const fn message(self) -> SafeMessage {
+ self.message
+ }
+}
+
+impl Debug for SafeError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("SafeError")
+ .field("code", &self.code)
+ .field("message", &self.message)
+ .finish()
+ }
+}
+
+impl Display for SafeError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ Display::fmt(&self.message, formatter)
+ }
+}
+
+impl Error for SafeError {}
+
+#[cfg(test)]
+mod tests {
+ use super::{SafeError, SafeErrorCode, SafeMessage};
+
+ #[test]
+ fn safe_error_formats_only_a_static_public_message() {
+ let error = SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The secret key is invalid."),
+ );
+
+ assert_eq!(error.to_string(), "The secret key is invalid.");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ assert_eq!(error.message().as_str(), "The secret key is invalid.");
+ assert!(!format!("{error:?}").contains("nsec1unsafe-test-value"));
+ }
+}
diff --git a/core/crates/studio_domain/src/key.rs b/core/crates/studio_domain/src/key.rs
@@ -0,0 +1,451 @@
+//! Validated Nostr public and secret-key boundary values.
+
+use std::fmt::{self, Display, Formatter};
+use std::str::FromStr;
+
+use secrecy::{ExposeSecret, SecretString};
+use zeroize::Zeroizing;
+
+use crate::{SafeError, SafeErrorCode, SafeMessage};
+
+pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32;
+pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2;
+pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128;
+const NIP19_KEY_LENGTH: usize = 63;
+const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct Npub(String);
+
+impl Npub {
+ /// Constructs a human-facing npub after structural validation.
+ ///
+ /// Cryptographic conversion and checksum validation are performed by the
+ /// selected Nostr adapter before this domain value is created in runtime
+ /// flows.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-public-key error for a malformed npub shape.
+ pub fn from_encoded(value: String) -> Result<Self, SafeError> {
+ if !is_nip19_key_shape(&value, "npub1") {
+ return Err(invalid_public_key());
+ }
+ Ok(Self(value))
+ }
+
+ /// Derives the canonical NIP-19 display identity from a public key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error if canonical encoding fails.
+ pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
+ let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?;
+ bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes())
+ .map_err(|_| invalid_public_key())
+ .and_then(Self::from_encoded)
+ }
+
+ /// Validates that encoded display identity belongs to the canonical key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error when the values do not match.
+ pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> {
+ let candidate = Self::from_encoded(encoded)?;
+ if candidate != Self::derive(public_key)? {
+ return Err(invalid_public_key());
+ }
+ Ok(candidate)
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ #[must_use]
+ pub fn short(&self) -> String {
+ format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..])
+ }
+}
+
+impl Display for Npub {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+pub struct Nsec(SecretString);
+
+impl Nsec {
+ /// Constructs a secret nsec display value after structural validation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error for a malformed nsec shape.
+ pub fn from_encoded(value: String) -> Result<Self, SafeError> {
+ if !is_nip19_key_shape(&value, "nsec1") {
+ return Err(invalid_secret_key());
+ }
+ Ok(Self(SecretString::from(value)))
+ }
+
+ pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
+ operation(self.0.expose_secret())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SecretKeyInputKind {
+ Nsec,
+ Hex,
+}
+
+pub struct SecretKeyInput {
+ value: SecretString,
+ kind: SecretKeyInputKind,
+}
+
+impl SecretKeyInput {
+ /// Moves bounded transport bytes into the zeroizing secret boundary.
+ ///
+ /// The source byte allocation is cleared on every return path.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or
+ /// structurally invalid input.
+ pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> {
+ let value = Zeroizing::new(value);
+ if value.len() > MAX_SECRET_KEY_INPUT_BYTES {
+ return Err(invalid_secret_key());
+ }
+ let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?;
+ Self::parse(encoded.to_owned())
+ }
+
+ /// Moves one secret input string into a zeroizing boundary.
+ ///
+ /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter.
+ /// Hex input is structurally validated here to prevent ambiguous fallback.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error when the input is neither an
+ /// nsec-looking value nor exactly 64 lowercase hexadecimal characters.
+ pub fn parse(value: String) -> Result<Self, SafeError> {
+ let mut value = Zeroizing::new(value);
+ let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ SecretKeyInputKind::Hex
+ } else if is_nip19_key_shape(&value, "nsec1") {
+ SecretKeyInputKind::Nsec
+ } else {
+ return Err(invalid_secret_key());
+ };
+
+ Ok(Self {
+ value: SecretString::from(std::mem::take(&mut *value)),
+ kind,
+ })
+ }
+
+ #[must_use]
+ pub const fn kind(&self) -> SecretKeyInputKind {
+ self.kind
+ }
+
+ pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
+ operation(self.value.expose_secret())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct PublicKey(radroots_identity::PublicKey);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PersistedPublicKeyClassification {
+ Canonical(PublicKey),
+ NonCanonicalEncoding,
+ InvalidCurvePoint,
+ MalformedEncoding,
+}
+
+impl PublicKey {
+ /// Validates canonical x-only secp256k1 public-key bytes.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-public-key error when the bytes are not a valid
+ /// x-only secp256k1 point.
+ pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> {
+ radroots_identity::PublicKey::from_bytes(bytes)
+ .map(Self)
+ .map_err(|_| invalid_public_key())
+ }
+
+ /// Parses a canonical lowercase hexadecimal Nostr public key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-public-key error when the value is not exactly
+ /// 64 lowercase hexadecimal characters.
+ pub fn from_hex(value: &str) -> Result<Self, SafeError> {
+ if value.len() != PUBLIC_KEY_HEX_LENGTH
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(invalid_public_key());
+ }
+
+ radroots_identity::PublicKey::from_hex(value)
+ .map(Self)
+ .map_err(|_| invalid_public_key())
+ }
+
+ #[must_use]
+ pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] {
+ self.0.as_bytes()
+ }
+
+ #[must_use]
+ pub const fn canonical(self) -> radroots_identity::PublicKey {
+ self.0
+ }
+
+ #[must_use]
+ pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self {
+ Self(public_key)
+ }
+
+ #[must_use]
+ pub fn to_hex(self) -> String {
+ self.0.to_hex()
+ }
+
+ #[must_use]
+ pub fn short_hex(self) -> String {
+ let hex = self.to_hex();
+ format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..])
+ }
+}
+
+impl Display for PublicKey {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.to_hex())
+ }
+}
+
+impl From<radroots_identity::PublicKey> for PublicKey {
+ fn from(value: radroots_identity::PublicKey) -> Self {
+ Self::from_canonical(value)
+ }
+}
+
+impl From<PublicKey> for radroots_identity::PublicKey {
+ fn from(value: PublicKey) -> Self {
+ value.canonical()
+ }
+}
+
+impl FromStr for PublicKey {
+ type Err = SafeError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::from_hex(value)
+ }
+}
+
+const fn invalid_public_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidPublicKey,
+ SafeMessage::new("The Nostr public key is invalid."),
+ )
+}
+
+const fn invalid_secret_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr secret key is invalid."),
+ )
+}
+
+#[must_use]
+pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification {
+ if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return PersistedPublicKeyClassification::MalformedEncoding;
+ }
+ if !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return PersistedPublicKeyClassification::NonCanonicalEncoding;
+ }
+ match PublicKey::from_hex(value) {
+ Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key),
+ Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint,
+ }
+}
+
+fn is_nip19_key_shape(value: &str, prefix: &str) -> bool {
+ value.len() == NIP19_KEY_LENGTH
+ && value.starts_with(prefix)
+ && value[prefix.len()..]
+ .bytes()
+ .all(|byte| BECH32_DATA_CHARSET.contains(&byte))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use super::{
+ MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH,
+ PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind,
+ classify_persisted_public_key,
+ };
+ use crate::SafeErrorCode;
+
+ const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
+ const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+
+ #[test]
+ fn public_key_round_trips_canonical_hex_and_bytes() {
+ let key = PublicKey::from_str(HEX).expect("valid public key");
+
+ assert_eq!(key.to_hex(), HEX);
+ assert_eq!(key.to_string(), HEX);
+ assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7");
+ assert_eq!(
+ PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"),
+ key
+ );
+ assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH);
+ }
+
+ #[test]
+ fn public_key_rejects_noncanonical_or_malformed_hex() {
+ for value in [
+ "",
+ "00",
+ "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7",
+ "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ] {
+ let error = PublicKey::from_hex(value).expect_err("invalid public key");
+ assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey);
+ }
+ }
+
+ #[test]
+ fn public_keys_are_ordered_by_canonical_bytes() {
+ let low =
+ PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
+ .expect("low key");
+ let high =
+ PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af")
+ .expect("high key");
+
+ assert!(low < high);
+ }
+
+ #[test]
+ fn persisted_public_key_classification_is_explicit_and_fail_closed() {
+ assert!(matches!(
+ classify_persisted_public_key(HEX),
+ PersistedPublicKeyClassification::Canonical(_)
+ ));
+ assert_eq!(
+ classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()),
+ PersistedPublicKeyClassification::NonCanonicalEncoding
+ );
+ assert_eq!(
+ classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)),
+ PersistedPublicKeyClassification::InvalidCurvePoint
+ );
+ assert_eq!(
+ classify_persisted_public_key("not-a-public-key"),
+ PersistedPublicKeyClassification::MalformedEncoding
+ );
+ }
+
+ #[test]
+ fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() {
+ let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH);
+ let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex");
+
+ assert_eq!(input.kind(), SecretKeyInputKind::Hex);
+ assert_eq!(input.with_exposed_secret(str::len), secret.len());
+ assert_eq!(input.with_exposed_secret(str::len), 64);
+ }
+
+ #[test]
+ fn secret_input_accepts_nsec_shape_without_exposing_it() {
+ let secret = NSEC.to_owned();
+ let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input");
+
+ assert_eq!(input.kind(), SecretKeyInputKind::Nsec);
+ assert_eq!(input.with_exposed_secret(str::len), secret.len());
+ }
+
+ #[test]
+ fn secret_input_rejects_invalid_hex_and_arbitrary_text() {
+ for value in [
+ "",
+ "very-sensitive-input",
+ &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH),
+ ] {
+ let Err(error) = SecretKeyInput::parse(value.to_owned()) else {
+ panic!("invalid secret accepted");
+ };
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ if !value.is_empty() {
+ assert!(!format!("{error:?}").contains(value));
+ }
+ }
+ }
+
+ #[test]
+ fn secret_byte_transport_is_bounded_and_validated() {
+ let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes");
+ assert_eq!(parsed.with_exposed_secret(str::len), 64);
+ assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err());
+ assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err());
+ }
+
+ #[test]
+ fn npub_is_public_display_data_but_not_canonical_identity() {
+ let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape");
+
+ assert_eq!(npub.as_str(), NPUB);
+ assert_eq!(npub.to_string(), NPUB);
+ }
+
+ #[test]
+ fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() {
+ let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape");
+
+ assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
+ assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
+ }
+
+ #[test]
+ fn nip19_display_types_reject_wrong_prefix_length_and_charset() {
+ for invalid in [
+ "",
+ "npub1short",
+ "nsec1short",
+ "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g",
+ ] {
+ assert!(Npub::from_encoded(invalid.to_owned()).is_err());
+ assert!(Nsec::from_encoded(invalid.to_owned()).is_err());
+ }
+ }
+}
diff --git a/core/crates/studio_domain/src/lib.rs b/core/crates/studio_domain/src/lib.rs
@@ -0,0 +1,21 @@
+#![doc = "Radroots Studio Nostr account domain types."]
+
+pub mod account;
+pub mod error;
+pub mod key;
+pub mod profile;
+pub mod relay;
+pub mod time;
+
+pub use account::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ BindingRepairAction, LocalSignerBinding,
+};
+pub use error::{SafeError, SafeErrorCode, SafeMessage};
+pub use key::{
+ MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey,
+ SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key,
+};
+pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
+pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls};
+pub use time::UnixTimestamp;
diff --git a/core/crates/studio_domain/src/profile.rs b/core/crates/studio_domain/src/profile.rs
@@ -0,0 +1,298 @@
+//! Public Nostr profile metadata values.
+
+use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
+
+const EVENT_ID_BYTES: usize = 32;
+const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2;
+const MAX_NAME_CHARS: usize = 128;
+const MAX_NIP05_CHARS: usize = 320;
+const MAX_ABOUT_CHARS: usize = 4_096;
+const MAX_PICTURE_CHARS: usize = 2_048;
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct EventId([u8; EVENT_ID_BYTES]);
+
+impl EventId {
+ /// Parses a canonical lowercase hexadecimal Nostr event ID.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe profile error for malformed input.
+ pub fn from_hex(value: &str) -> Result<Self, SafeError> {
+ if value.len() != EVENT_ID_HEX
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(invalid_profile_metadata());
+ }
+
+ let mut bytes = [0_u8; EVENT_ID_BYTES];
+ for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+ let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?;
+ let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?;
+ bytes[index] = (high << 4) | low;
+ }
+ Ok(Self(bytes))
+ }
+
+ #[must_use]
+ pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self {
+ Self(bytes)
+ }
+
+ #[must_use]
+ pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] {
+ self.0
+ }
+
+ #[must_use]
+ pub fn to_hex(self) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(EVENT_ID_HEX);
+ for byte in self.0 {
+ output.push(char::from(HEX[usize::from(byte >> 4)]));
+ output.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ output
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct ProfileMetadata {
+ name: Option<String>,
+ display_name: Option<String>,
+ nip05: Option<String>,
+ about: Option<String>,
+ picture: Option<String>,
+}
+
+impl ProfileMetadata {
+ /// Normalizes and bounds public kind-0 profile fields.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe profile error when a field exceeds its limit or contains
+ /// a forbidden control character.
+ pub fn new(
+ name: Option<String>,
+ display_name: Option<String>,
+ nip05: Option<String>,
+ about: Option<String>,
+ picture: Option<String>,
+ ) -> Result<Self, SafeError> {
+ Ok(Self {
+ name: normalize_field(name, MAX_NAME_CHARS, false)?,
+ display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?,
+ nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?,
+ about: normalize_field(about, MAX_ABOUT_CHARS, true)?,
+ picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?,
+ })
+ }
+
+ #[must_use]
+ pub fn name(&self) -> Option<&str> {
+ self.name.as_deref()
+ }
+
+ #[must_use]
+ pub fn display_name(&self) -> Option<&str> {
+ self.display_name.as_deref()
+ }
+
+ #[must_use]
+ pub fn nip05(&self) -> Option<&str> {
+ self.nip05.as_deref()
+ }
+
+ #[must_use]
+ pub fn about(&self) -> Option<&str> {
+ self.about.as_deref()
+ }
+
+ #[must_use]
+ pub fn picture(&self) -> Option<&str> {
+ self.picture.as_deref()
+ }
+
+ #[must_use]
+ pub fn preferred_name(&self) -> Option<&str> {
+ self.display_name().or_else(|| self.name())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Kind0ProfileCandidate {
+ event_id: EventId,
+ author: PublicKey,
+ created_at: UnixTimestamp,
+ metadata: ProfileMetadata,
+}
+
+impl Kind0ProfileCandidate {
+ #[must_use]
+ pub const fn new(
+ event_id: EventId,
+ author: PublicKey,
+ created_at: UnixTimestamp,
+ metadata: ProfileMetadata,
+ ) -> Self {
+ Self {
+ event_id,
+ author,
+ created_at,
+ metadata,
+ }
+ }
+
+ #[must_use]
+ pub const fn event_id(&self) -> EventId {
+ self.event_id
+ }
+
+ #[must_use]
+ pub const fn author(&self) -> PublicKey {
+ self.author
+ }
+
+ #[must_use]
+ pub const fn created_at(&self) -> UnixTimestamp {
+ self.created_at
+ }
+
+ #[must_use]
+ pub const fn metadata(&self) -> &ProfileMetadata {
+ &self.metadata
+ }
+}
+
+#[must_use]
+pub fn select_latest_kind0(
+ candidates: impl IntoIterator<Item = Kind0ProfileCandidate>,
+) -> Option<Kind0ProfileCandidate> {
+ candidates.into_iter().reduce(|selected, candidate| {
+ if candidate.created_at > selected.created_at
+ || (candidate.created_at == selected.created_at
+ && candidate.event_id < selected.event_id)
+ {
+ candidate
+ } else {
+ selected
+ }
+ })
+}
+
+fn normalize_field(
+ value: Option<String>,
+ max_chars: usize,
+ allow_layout_controls: bool,
+) -> Result<Option<String>, SafeError> {
+ let Some(value) = value else {
+ return Ok(None);
+ };
+ let normalized = value.trim();
+ if normalized.is_empty() {
+ return Ok(None);
+ }
+ if normalized.chars().count() > max_chars
+ || normalized.chars().any(|character| {
+ character.is_control()
+ && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t'))
+ })
+ {
+ return Err(invalid_profile_metadata());
+ }
+ Ok(Some(normalized.to_owned()))
+}
+
+const fn invalid_profile_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidProfileMetadata,
+ SafeMessage::new("The Nostr profile metadata is invalid."),
+ )
+}
+
+const fn decode_hex(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{PublicKey, UnixTimestamp};
+
+ use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
+
+ fn profile(name: &str) -> ProfileMetadata {
+ ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile")
+ }
+
+ fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate {
+ Kind0ProfileCandidate::new(
+ EventId::from_bytes([id_byte; 32]),
+ PublicKey::from_bytes([7_u8; 32]).expect("valid public key"),
+ UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
+ profile(name),
+ )
+ }
+
+ #[test]
+ fn profile_fields_are_trimmed_bounded_and_public() {
+ let metadata = ProfileMetadata::new(
+ Some(" farmer ".to_owned()),
+ Some(" Farm Account ".to_owned()),
+ Some("farmer@example.test".to_owned()),
+ Some("First line\nSecond line".to_owned()),
+ Some("https://images.example.test/profile.png".to_owned()),
+ )
+ .expect("valid profile");
+
+ assert_eq!(metadata.name(), Some("farmer"));
+ assert_eq!(metadata.display_name(), Some("Farm Account"));
+ assert_eq!(metadata.preferred_name(), Some("Farm Account"));
+ assert_eq!(metadata.nip05(), Some("farmer@example.test"));
+ assert_eq!(metadata.about(), Some("First line\nSecond line"));
+ assert_eq!(
+ metadata.picture(),
+ Some("https://images.example.test/profile.png")
+ );
+ }
+
+ #[test]
+ fn profile_fields_reject_forbidden_controls_and_oversize_values() {
+ assert!(
+ ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err()
+ );
+ assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err());
+ }
+
+ #[test]
+ fn latest_kind0_uses_timestamp_then_lowest_event_id() {
+ let older = candidate(0, 10, "older");
+ let equal_high_id = candidate(9, 20, "high-id");
+ let equal_low_id = candidate(1, 20, "low-id");
+
+ let selected =
+ select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile");
+
+ assert_eq!(selected.metadata().name(), Some("low-id"));
+ assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]);
+ assert_eq!(
+ selected.author(),
+ PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
+ );
+ assert_eq!(selected.created_at().as_seconds(), 20);
+ }
+
+ #[test]
+ fn event_id_rejects_noncanonical_hex_and_round_trips() {
+ let hex = "12".repeat(32);
+ let event_id = EventId::from_hex(&hex).expect("valid event id");
+
+ assert_eq!(event_id.to_hex(), hex);
+ assert!(EventId::from_hex(&"GG".repeat(32)).is_err());
+ }
+}
diff --git a/core/crates/studio_domain/src/relay.rs b/core/crates/studio_domain/src/relay.rs
@@ -0,0 +1,198 @@
+//! Validated Nostr relay values.
+
+use std::collections::HashSet;
+use std::fmt::{self, Display, Formatter};
+
+use url::{Host, Url};
+
+use crate::{SafeError, SafeErrorCode, SafeMessage};
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub enum RelayDestinationPolicy {
+ Public,
+ Local,
+ PrivateNetwork,
+}
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct RelayUrl {
+ value: String,
+ policy: RelayDestinationPolicy,
+}
+
+impl RelayUrl {
+ /// Parses and normalizes an allowed WebSocket relay URL.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe configuration error for empty or malformed input,
+ /// forbidden schemes, credentials, fragments, or non-loopback `ws://`.
+ pub fn parse(value: &str, policy: RelayDestinationPolicy) -> Result<Self, SafeError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
+ return Err(invalid_relay());
+ }
+
+ let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?;
+ if !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.fragment().is_some()
+ {
+ return Err(invalid_relay());
+ }
+
+ match (policy, parsed.scheme()) {
+ (RelayDestinationPolicy::Public | RelayDestinationPolicy::PrivateNetwork, "wss") => {}
+ (RelayDestinationPolicy::Local, "ws" | "wss") if is_loopback(&parsed) => {}
+ _ => return Err(invalid_relay()),
+ }
+
+ if parsed.host().is_none() {
+ return Err(invalid_relay());
+ }
+
+ Ok(Self {
+ value: parsed.to_string(),
+ policy,
+ })
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.value
+ }
+
+ #[must_use]
+ pub const fn policy(&self) -> RelayDestinationPolicy {
+ self.policy
+ }
+}
+
+impl Display for RelayUrl {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.value)
+ }
+}
+
+/// Parses relay values and removes duplicates without changing first-seen order.
+///
+/// # Errors
+///
+/// Returns the first safe relay validation error.
+pub fn normalize_relay_urls<I, S>(
+ values: I,
+ policy: RelayDestinationPolicy,
+) -> Result<Vec<RelayUrl>, SafeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let mut seen = HashSet::new();
+ let mut relays = Vec::new();
+ for value in values {
+ let relay = RelayUrl::parse(value.as_ref(), policy)?;
+ if seen.insert(relay.clone()) {
+ relays.push(relay);
+ }
+ }
+ Ok(relays)
+}
+
+fn is_loopback(url: &Url) -> bool {
+ match url.host() {
+ Some(Host::Domain(domain)) => domain == "localhost",
+ Some(Host::Ipv4(address)) => address.octets()[0] == 127,
+ Some(Host::Ipv6(address)) => address.is_loopback(),
+ None => false,
+ }
+}
+
+const fn invalid_relay() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidRelayConfiguration,
+ SafeMessage::new("The Nostr relay URL is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls};
+ use crate::SafeErrorCode;
+
+ #[test]
+ fn relay_accepts_secure_remote_and_loopback_development_urls() {
+ for (input, policy, expected) in [
+ (
+ " wss://Relay.Example/path ",
+ RelayDestinationPolicy::Public,
+ "wss://relay.example/path",
+ ),
+ (
+ "ws://localhost:8080",
+ RelayDestinationPolicy::Local,
+ "ws://localhost:8080/",
+ ),
+ (
+ "ws://127.42.1.9:8080",
+ RelayDestinationPolicy::Local,
+ "ws://127.42.1.9:8080/",
+ ),
+ (
+ "ws://[::1]:8080",
+ RelayDestinationPolicy::Local,
+ "ws://[::1]:8080/",
+ ),
+ ] {
+ let relay = RelayUrl::parse(input, policy).expect("allowed relay");
+ assert_eq!(relay.as_str(), expected);
+ assert_eq!(relay.to_string(), expected);
+ }
+ }
+
+ #[test]
+ fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() {
+ for input in [
+ "",
+ "https://relay.example",
+ "http://localhost:8080",
+ "wss://user:password@relay.example",
+ "wss://relay.example/#fragment",
+ "ws://relay.example",
+ "ws://192.168.1.2:8080",
+ "ws://localhost.evil.example:8080",
+ "wss://relay.example/\nunsafe",
+ ] {
+ let error = RelayUrl::parse(input, RelayDestinationPolicy::Public)
+ .expect_err("forbidden relay");
+ assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+ }
+
+ #[test]
+ fn relay_deduplication_preserves_normalized_first_seen_order() {
+ let relays = normalize_relay_urls(
+ [
+ "wss://relay.example",
+ " wss://second.example/path ",
+ "wss://RELAY.example/",
+ "wss://second.example/path",
+ ],
+ RelayDestinationPolicy::Public,
+ )
+ .expect("valid relays");
+
+ assert_eq!(
+ relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(),
+ vec!["wss://relay.example/", "wss://second.example/path"]
+ );
+ }
+
+ #[test]
+ fn relay_destination_policy_is_explicit_and_fail_closed() {
+ assert!(RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Public).is_err());
+ assert!(RelayUrl::parse("wss://relay.example", RelayDestinationPolicy::Local).is_err());
+ let private = RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::PrivateNetwork)
+ .expect("explicit private network");
+ assert_eq!(private.policy(), RelayDestinationPolicy::PrivateNetwork);
+ }
+}
diff --git a/core/crates/studio_domain/src/time.rs b/core/crates/studio_domain/src/time.rs
@@ -0,0 +1,36 @@
+//! Time values shared by account and profile records.
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct UnixTimestamp(i64);
+
+impl UnixTimestamp {
+ pub const UNIX_EPOCH: Self = Self(0);
+
+ #[must_use]
+ pub const fn from_seconds(seconds: i64) -> Option<Self> {
+ if seconds < 0 {
+ None
+ } else {
+ Some(Self(seconds))
+ }
+ }
+
+ #[must_use]
+ pub const fn as_seconds(self) -> i64 {
+ self.0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::UnixTimestamp;
+
+ #[test]
+ fn timestamp_rejects_negative_seconds() {
+ assert_eq!(UnixTimestamp::from_seconds(-1), None);
+ assert_eq!(
+ UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds),
+ Some(0)
+ );
+ }
+}