commit 8c01bc55dc0471535284a1ab7d13c4cb5b44ddb8
parent d021c4a305969ef3b9b95ce32e4afa376bdafa7d
Author: triesap <tyson@radroots.org>
Date: Sun, 4 Jan 2026 22:11:52 +0000
events: add account claim kind and username helpers
- Add KIND_ACCOUNT_CLAIM constant and wire it into kinds exports
- Introduce RadrootsAccountClaim event types with ts-rs bindings
- Add identity username validation/normalization with exported constants
- Extend TS bindings to include account claim types and radrootsd profile type
Diffstat:
11 files changed, 177 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1769,6 +1769,7 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"tracing",
+ "ts-rs",
]
[[package]]
diff --git a/events/bindings/ts/src/kinds.ts b/events/bindings/ts/src/kinds.ts
@@ -44,6 +44,7 @@ export const KIND_COOP = 30360;
export const KIND_DOCUMENT = 30361;
export const KIND_RESOURCE_AREA = 30370;
export const KIND_RESOURCE_HARVEST_CAP = 30371;
+export const KIND_ACCOUNT_CLAIM = 30380;
export const KIND_APP_DATA = 30078;
export const KIND_LISTING = 30402;
export const KIND_APPLICATION_HANDLER = 31990;
diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts
@@ -8,6 +8,12 @@ export type JobInputType = "url" | "event" | "job" | "text";
export type JobPaymentRequest = { amount_sat: number, bolt11?: string | null, };
+export type RadrootsAccountClaim = { username: string, pubkey: string, nip05?: string | null, };
+
+export type RadrootsAccountClaimEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsAccountClaimEventMetadata, };
+
+export type RadrootsAccountClaimEventMetadata = { id: string, author: string, published_at: number, kind: number, account: RadrootsAccountClaim, };
+
export type RadrootsAppData = { d_tag: string, content: string, };
export type RadrootsAppDataEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsAppDataEventMetadata, };
@@ -176,7 +182,7 @@ export type RadrootsProfileEventIndex = { event: RadrootsNostrEvent, metadata: R
export type RadrootsProfileEventMetadata = { id: string, author: string, published_at: number, kind: number, profile_type?: RadrootsProfileType | null, profile: RadrootsProfile, };
-export type RadrootsProfileType = "individual" | "farm" | "coop" | "any";
+export type RadrootsProfileType = "individual" | "farm" | "coop" | "any" | "radrootsd";
export type RadrootsReaction = { root: RadrootsNostrEventRef, content: string, };
diff --git a/events/src/account.rs b/events/src/account.rs
@@ -0,0 +1,40 @@
+use crate::{kinds::KIND_ACCOUNT_CLAIM as KIND_ACCOUNT_CLAIM_EVENT, RadrootsNostrEvent};
+#[cfg(feature = "ts-rs")]
+use ts_rs::TS;
+
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
+
+pub const KIND_ACCOUNT_CLAIM: u32 = KIND_ACCOUNT_CLAIM_EVENT;
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsAccountClaimEventIndex {
+ pub event: RadrootsNostrEvent,
+ pub metadata: RadrootsAccountClaimEventMetadata,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsAccountClaimEventMetadata {
+ pub id: String,
+ pub author: String,
+ pub published_at: u32,
+ pub kind: u32,
+ pub account: RadrootsAccountClaim,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsAccountClaim {
+ pub username: String,
+ pub pubkey: String,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub nip05: Option<String>,
+}
diff --git a/events/src/kinds.rs b/events/src/kinds.rs
@@ -44,6 +44,7 @@ pub const KIND_COOP: u32 = 30360;
pub const KIND_DOCUMENT: u32 = 30361;
pub const KIND_RESOURCE_AREA: u32 = 30370;
pub const KIND_RESOURCE_HARVEST_CAP: u32 = 30371;
+pub const KIND_ACCOUNT_CLAIM: u32 = 30380;
pub const KIND_APP_DATA: u32 = 30078;
pub const KIND_LISTING: u32 = 30402;
pub const KIND_APPLICATION_HANDLER: u32 = 31990;
@@ -175,6 +176,7 @@ mod kinds_constants_tests {
("KIND_DOCUMENT", KIND_DOCUMENT),
("KIND_RESOURCE_AREA", KIND_RESOURCE_AREA),
("KIND_RESOURCE_HARVEST_CAP", KIND_RESOURCE_HARVEST_CAP),
+ ("KIND_ACCOUNT_CLAIM", KIND_ACCOUNT_CLAIM),
("KIND_APP_DATA", KIND_APP_DATA),
("KIND_LISTING", KIND_LISTING),
("KIND_APPLICATION_HANDLER", KIND_APPLICATION_HANDLER),
diff --git a/events/src/lib.rs b/events/src/lib.rs
@@ -10,6 +10,7 @@ use ts_rs::TS;
use alloc::{string::String, vec::Vec};
pub mod comment;
+pub mod account;
pub mod follow;
pub mod gift_wrap;
pub mod job;
diff --git a/identity/Cargo.toml b/identity/Cargo.toml
@@ -5,10 +5,12 @@ edition.workspace = true
authors = ["Radroots Authors"]
rust-version.workspace = true
license.workspace = true
+build = "build.rs"
[features]
default = ["std"]
std = ["dep:radroots-runtime"]
+ts-rs = ["dep:ts-rs"]
[dependencies]
radroots-runtime = { workspace = true, optional = true }
@@ -18,6 +20,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
+ts-rs = { workspace = true, optional = true }
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/identity/bindings/ts/src/constants.ts b/identity/bindings/ts/src/constants.ts
@@ -0,0 +1,3 @@
+export const RADROOTS_USERNAME_MIN_LEN = 3;
+export const RADROOTS_USERNAME_MAX_LEN = 30;
+export const RADROOTS_USERNAME_REGEX = "^(?!.*\.\.)(?!\.)(?!.*\.$)[a-z0-9._-]{3,30}$";
diff --git a/identity/build.rs b/identity/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ println!("cargo:rustc-env=TS_RS_EXPORT_DIR=./bindings/ts/src");
+}
diff --git a/identity/src/lib.rs b/identity/src/lib.rs
@@ -6,9 +6,14 @@ extern crate alloc;
pub mod error;
pub mod identity;
+pub mod username;
pub use error::IdentityError;
pub use identity::{
RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityProfile,
RadrootsIdentitySecretKeyFormat, DEFAULT_IDENTITY_PATH,
};
+pub use username::{
+ radroots_username_is_valid, radroots_username_normalize, RADROOTS_USERNAME_MAX_LEN,
+ RADROOTS_USERNAME_MIN_LEN, RADROOTS_USERNAME_REGEX,
+};
diff --git a/identity/src/username.rs b/identity/src/username.rs
@@ -0,0 +1,111 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
+
+pub const RADROOTS_USERNAME_MIN_LEN: usize = 3;
+pub const RADROOTS_USERNAME_MAX_LEN: usize = 30;
+pub const RADROOTS_USERNAME_REGEX: &str =
+ r"^(?!.*\.\.)(?!\.)(?!.*\.$)[a-z0-9._-]{3,30}$";
+
+pub fn radroots_username_is_valid(username: &str) -> bool {
+ if !username.is_ascii() {
+ return false;
+ }
+ let len = username.len();
+ if len < RADROOTS_USERNAME_MIN_LEN || len > RADROOTS_USERNAME_MAX_LEN {
+ return false;
+ }
+ let bytes = username.as_bytes();
+ if bytes.first() == Some(&b'.') || bytes.last() == Some(&b'.') {
+ return false;
+ }
+ let mut prev_dot = false;
+ for &byte in bytes {
+ if byte == b'.' {
+ if prev_dot {
+ return false;
+ }
+ prev_dot = true;
+ continue;
+ }
+ prev_dot = false;
+ let is_alpha = byte.is_ascii_lowercase();
+ let is_digit = byte.is_ascii_digit();
+ let is_allowed = is_alpha || is_digit || byte == b'_' || byte == b'-';
+ if !is_allowed {
+ return false;
+ }
+ }
+ true
+}
+
+pub fn radroots_username_normalize(input: &str) -> Option<String> {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let normalized = trimmed.to_ascii_lowercase();
+ if radroots_username_is_valid(&normalized) {
+ Some(normalized)
+ } else {
+ None
+ }
+}
+
+#[cfg(all(test, feature = "std"))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn valid_usernames() {
+ for name in ["radroots", "radroots_1", "radroots.test", "rr-01"] {
+ assert!(radroots_username_is_valid(name));
+ }
+ }
+
+ #[test]
+ fn invalid_usernames() {
+ for name in [
+ "ra",
+ ".radroots",
+ "radroots.",
+ "radroots..test",
+ "radroots!",
+ "RADROOTS",
+ ] {
+ assert!(!radroots_username_is_valid(name));
+ }
+ }
+
+ #[test]
+ fn normalize_usernames() {
+ assert_eq!(
+ radroots_username_normalize(" RadRoots "),
+ Some("radroots".to_string())
+ );
+ assert_eq!(radroots_username_normalize("ra"), None);
+ }
+}
+
+#[cfg(all(test, feature = "ts-rs", feature = "std"))]
+mod constants_tests {
+ use super::*;
+ use std::{env, fs, path::Path};
+
+ #[test]
+ fn export_username_constants() {
+ let out_dir = env::var("TS_RS_EXPORT_DIR").unwrap_or_else(|_| "./bindings".to_string());
+ let path = Path::new(&out_dir).join("constants.ts");
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).expect("create ts export dir");
+ }
+ let content = format!(
+ "export const RADROOTS_USERNAME_MIN_LEN = {min_len};\nexport const RADROOTS_USERNAME_MAX_LEN = {max_len};\nexport const RADROOTS_USERNAME_REGEX = \"{regex}\";\n",
+ min_len = RADROOTS_USERNAME_MIN_LEN,
+ max_len = RADROOTS_USERNAME_MAX_LEN,
+ regex = RADROOTS_USERNAME_REGEX
+ );
+ fs::write(&path, content).expect("write constants");
+ }
+}