tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit a000036f46a091270b6c7598f36a3995350143d5
parent 1cedbf233ff9b2c40e210b09b96e1a2f9627a6db
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 16:15:21 -0700

build: add nip29 config skeleton crates

- add the tangle_groups crate for v2 group runtime config types
- add the tangle_store_pocket crate for explicit Pocket storage config
- wire both crates into the workspace and lockfile
- cover relay secret redaction, strict config validation, and Pocket bounds

Diffstat:
MCargo.lock | 13+++++++++++++
MCargo.toml | 2++
Acrates/tangle_groups/Cargo.toml | 16++++++++++++++++
Acrates/tangle_groups/src/lib.rs | 542+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_store_pocket/Cargo.toml | 13+++++++++++++
Acrates/tangle_store_pocket/src/lib.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 738 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4160,6 +4160,15 @@ dependencies = [ ] [[package]] +name = "tangle_groups" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tangle_protocol", +] + +[[package]] name = "tangle_nips" version = "0.1.0" dependencies = [ @@ -4206,6 +4215,10 @@ dependencies = [ ] [[package]] +name = "tangle_store_pocket" +version = "0.1.0" + +[[package]] name = "tangle_store_surreal" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -4,10 +4,12 @@ members = [ "crates/tangle_bench", "crates/tangle_core", "crates/tangle_crypto", + "crates/tangle_groups", "crates/tangle_nips", "crates/tangle_protocol", "crates/tangle_runtime", "crates/tangle_store", + "crates/tangle_store_pocket", "crates/tangle_store_surreal", "crates/tangle_test_support", ] diff --git a/crates/tangle_groups/Cargo.toml b/crates/tangle_groups/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tangle_groups" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "NIP-29 group domain and configuration types for tangle" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tangle_protocol = { path = "../tangle_protocol" } + +[lints] +workspace = true diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -0,0 +1,542 @@ +#![forbid(unsafe_code)] + +use core::fmt; +use serde::Deserialize; +use tangle_protocol::PublicKeyHex; + +#[derive(Clone, PartialEq, Eq)] +pub struct RelaySecret(String); + +impl RelaySecret { + pub const HEX_LENGTH: usize = 64; + + pub fn from_hex(value: &str) -> Result<Self, GroupConfigError> { + require_lowercase_hex("groups.relay_secret", value, Self::HEX_LENGTH)?; + Ok(Self(value.to_owned())) + } + + pub fn expose_for_signing(&self) -> &str { + &self.0 + } + + pub fn redacted(&self) -> &'static str { + "<redacted>" + } +} + +impl fmt::Debug for RelaySecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RelaySecret(<redacted>)") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalRelayUrl(String); + +impl CanonicalRelayUrl { + pub fn new(value: &str) -> Result<Self, GroupConfigError> { + if value.is_empty() { + return Err(GroupConfigError::invalid( + "groups.canonical_relay_url is required", + )); + } + if value.trim() != value { + return Err(GroupConfigError::invalid( + "groups.canonical_relay_url must not contain leading or trailing whitespace", + )); + } + if !(value.starts_with("ws://") || value.starts_with("wss://")) { + return Err(GroupConfigError::invalid( + "groups.canonical_relay_url must start with ws:// or wss://", + )); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CanonicalRelayUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub struct GroupCompatibilityConfig { + #[serde(default)] + zooid_closed_means_restricted: bool, +} + +impl GroupCompatibilityConfig { + pub fn strict() -> Self { + Self { + zooid_closed_means_restricted: false, + } + } + + pub fn zooid_closed_means_restricted(&self) -> bool { + self.zooid_closed_means_restricted + } +} + +impl Default for GroupCompatibilityConfig { + fn default() -> Self { + Self::strict() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub struct GroupRedactionConfig { + #[serde(default = "default_true")] + redact_private_tags: bool, + #[serde(default = "default_true")] + redact_invite_codes: bool, +} + +impl GroupRedactionConfig { + pub fn strict() -> Self { + Self { + redact_private_tags: true, + redact_invite_codes: true, + } + } + + pub fn redact_private_tags(&self) -> bool { + self.redact_private_tags + } + + pub fn redact_invite_codes(&self) -> bool { + self.redact_invite_codes + } +} + +impl Default for GroupRedactionConfig { + fn default() -> Self { + Self::strict() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub struct GroupLimitsConfig { + #[serde(default = "default_max_group_id_bytes")] + max_group_id_bytes: u16, + #[serde(default = "default_max_group_tags_per_event")] + max_group_tags_per_event: u16, + #[serde(default = "default_max_supported_kinds")] + max_supported_kinds: u16, + #[serde(default = "default_max_member_list_pubkeys")] + max_member_list_pubkeys: u32, + #[serde(default = "default_max_outbox_replay_batch")] + max_outbox_replay_batch: u32, +} + +impl GroupLimitsConfig { + pub fn new( + max_group_id_bytes: u16, + max_group_tags_per_event: u16, + max_supported_kinds: u16, + max_member_list_pubkeys: u32, + max_outbox_replay_batch: u32, + ) -> Result<Self, GroupConfigError> { + let value = Self { + max_group_id_bytes, + max_group_tags_per_event, + max_supported_kinds, + max_member_list_pubkeys, + max_outbox_replay_batch, + }; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), GroupConfigError> { + require_positive("groups.limits.max_group_id_bytes", self.max_group_id_bytes)?; + require_positive( + "groups.limits.max_group_tags_per_event", + self.max_group_tags_per_event, + )?; + require_positive( + "groups.limits.max_supported_kinds", + self.max_supported_kinds, + )?; + require_positive( + "groups.limits.max_member_list_pubkeys", + self.max_member_list_pubkeys, + )?; + require_positive( + "groups.limits.max_outbox_replay_batch", + self.max_outbox_replay_batch, + )?; + Ok(()) + } + + pub fn max_group_id_bytes(&self) -> u16 { + self.max_group_id_bytes + } + + pub fn max_group_tags_per_event(&self) -> u16 { + self.max_group_tags_per_event + } + + pub fn max_supported_kinds(&self) -> u16 { + self.max_supported_kinds + } + + pub fn max_member_list_pubkeys(&self) -> u32 { + self.max_member_list_pubkeys + } + + pub fn max_outbox_replay_batch(&self) -> u32 { + self.max_outbox_replay_batch + } +} + +impl Default for GroupLimitsConfig { + fn default() -> Self { + Self { + max_group_id_bytes: default_max_group_id_bytes(), + max_group_tags_per_event: default_max_group_tags_per_event(), + max_supported_kinds: default_max_supported_kinds(), + max_member_list_pubkeys: default_max_member_list_pubkeys(), + max_outbox_replay_batch: default_max_outbox_replay_batch(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupRuntimeConfig { + enabled: bool, + canonical_relay_url: Option<CanonicalRelayUrl>, + relay_secret: Option<RelaySecret>, + owner_pubkeys: Vec<PublicKeyHex>, + admin_pubkeys: Vec<PublicKeyHex>, + compatibility: GroupCompatibilityConfig, + redaction: GroupRedactionConfig, + limits: GroupLimitsConfig, +} + +impl GroupRuntimeConfig { + pub fn disabled() -> Self { + Self { + enabled: false, + canonical_relay_url: None, + relay_secret: None, + owner_pubkeys: Vec::new(), + admin_pubkeys: Vec::new(), + compatibility: GroupCompatibilityConfig::default(), + redaction: GroupRedactionConfig::default(), + limits: GroupLimitsConfig::default(), + } + } + + pub fn new( + enabled: bool, + canonical_relay_url: Option<CanonicalRelayUrl>, + relay_secret: Option<RelaySecret>, + owner_pubkeys: Vec<PublicKeyHex>, + admin_pubkeys: Vec<PublicKeyHex>, + compatibility: GroupCompatibilityConfig, + redaction: GroupRedactionConfig, + limits: GroupLimitsConfig, + ) -> Result<Self, GroupConfigError> { + limits.validate()?; + if enabled && canonical_relay_url.is_none() { + return Err(GroupConfigError::invalid( + "groups.canonical_relay_url is required when groups are enabled", + )); + } + if enabled && relay_secret.is_none() { + return Err(GroupConfigError::invalid( + "groups.relay_secret is required when groups are enabled", + )); + } + Ok(Self { + enabled, + canonical_relay_url, + relay_secret, + owner_pubkeys, + admin_pubkeys, + compatibility, + redaction, + limits, + }) + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn canonical_relay_url(&self) -> Option<&CanonicalRelayUrl> { + self.canonical_relay_url.as_ref() + } + + pub fn relay_secret(&self) -> Option<&RelaySecret> { + self.relay_secret.as_ref() + } + + pub fn owner_pubkeys(&self) -> &[PublicKeyHex] { + &self.owner_pubkeys + } + + pub fn admin_pubkeys(&self) -> &[PublicKeyHex] { + &self.admin_pubkeys + } + + pub fn compatibility(&self) -> GroupCompatibilityConfig { + self.compatibility + } + + pub fn redaction(&self) -> GroupRedactionConfig { + self.redaction + } + + pub fn limits(&self) -> GroupLimitsConfig { + self.limits + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupConfigError { + message: String, +} + +impl GroupConfigError { + pub fn invalid(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for GroupConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for GroupConfigError {} + +#[derive(Debug, Deserialize)] +struct GroupRuntimeConfigDocument { + enabled: bool, + canonical_relay_url: Option<String>, + relay_secret: Option<String>, + #[serde(default)] + owner_pubkeys: Vec<String>, + #[serde(default)] + admin_pubkeys: Vec<String>, + #[serde(default)] + compatibility: GroupCompatibilityConfig, + #[serde(default)] + redaction: GroupRedactionConfig, + #[serde(default)] + limits: GroupLimitsConfig, +} + +pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig, GroupConfigError> { + let document = serde_json::from_str::<GroupRuntimeConfigDocument>(raw).map_err(|error| { + GroupConfigError::invalid(format!("groups config JSON is invalid: {error}")) + })?; + let canonical_relay_url = document + .canonical_relay_url + .as_deref() + .map(CanonicalRelayUrl::new) + .transpose()?; + let relay_secret = document + .relay_secret + .as_deref() + .map(RelaySecret::from_hex) + .transpose()?; + GroupRuntimeConfig::new( + document.enabled, + canonical_relay_url, + relay_secret, + parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?, + parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?, + document.compatibility, + document.redaction, + document.limits, + ) +} + +fn parse_pubkeys(field: &str, values: Vec<String>) -> Result<Vec<PublicKeyHex>, GroupConfigError> { + values + .into_iter() + .map(|value| { + PublicKeyHex::new(&value).map_err(|error| { + GroupConfigError::invalid(format!("{field} contains invalid pubkey: {error}")) + }) + }) + .collect() +} + +fn require_lowercase_hex(field: &str, value: &str, length: usize) -> Result<(), GroupConfigError> { + if value.len() != length { + return Err(GroupConfigError::invalid(format!( + "{field} must be {length} lowercase hex characters" + ))); + } + if !value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(GroupConfigError::invalid(format!( + "{field} must be lowercase hex" + ))); + } + Ok(()) +} + +fn require_positive<T>(field: &str, value: T) -> Result<(), GroupConfigError> +where + T: Copy + PartialEq + From<u8> + fmt::Display, +{ + if value == T::from(0) { + return Err(GroupConfigError::invalid(format!( + "{field} must be greater than zero" + ))); + } + Ok(()) +} + +fn default_true() -> bool { + true +} + +fn default_max_group_id_bytes() -> u16 { + 128 +} + +fn default_max_group_tags_per_event() -> u16 { + 8 +} + +fn default_max_supported_kinds() -> u16 { + 512 +} + +fn default_max_member_list_pubkeys() -> u32 { + 100_000 +} + +fn default_max_outbox_replay_batch() -> u32 { + 1_000 +} + +#[cfg(test)] +mod tests { + use super::{ + CanonicalRelayUrl, GroupLimitsConfig, RelaySecret, parse_group_runtime_config_json, + }; + + #[test] + fn enabled_group_config_requires_relay_identity_material() { + let error = parse_group_runtime_config_json(r#"{"enabled": true}"#).expect_err("error"); + + assert_eq!( + error.message(), + "groups.canonical_relay_url is required when groups are enabled" + ); + } + + #[test] + fn enabled_group_config_parses_relay_identity_limits_and_flags() { + let owner = "1".repeat(64); + let admin = "2".repeat(64); + let secret = "3".repeat(64); + let raw = format!( + r#"{{ + "enabled": true, + "canonical_relay_url": "wss://relay.radroots.test", + "relay_secret": "{secret}", + "owner_pubkeys": ["{owner}"], + "admin_pubkeys": ["{admin}"], + "compatibility": {{"zooid_closed_means_restricted": true}}, + "redaction": {{"redact_private_tags": true, "redact_invite_codes": true}}, + "limits": {{ + "max_group_id_bytes": 64, + "max_group_tags_per_event": 4, + "max_supported_kinds": 32, + "max_member_list_pubkeys": 500, + "max_outbox_replay_batch": 25 + }} + }}"# + ); + + let config = parse_group_runtime_config_json(&raw).expect("config"); + + assert!(config.enabled()); + assert_eq!( + config.canonical_relay_url().expect("url").as_str(), + "wss://relay.radroots.test" + ); + assert_eq!(config.owner_pubkeys().len(), 1); + assert_eq!(config.admin_pubkeys().len(), 1); + assert!(config.compatibility().zooid_closed_means_restricted()); + assert!(config.redaction().redact_private_tags()); + assert!(config.redaction().redact_invite_codes()); + assert_eq!(config.limits().max_group_id_bytes(), 64); + assert_eq!(config.limits().max_group_tags_per_event(), 4); + assert_eq!(config.limits().max_supported_kinds(), 32); + assert_eq!(config.limits().max_member_list_pubkeys(), 500); + assert_eq!(config.limits().max_outbox_replay_batch(), 25); + } + + #[test] + fn disabled_group_config_does_not_require_relay_secret() { + let config = parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("config"); + + assert!(!config.enabled()); + assert!(config.canonical_relay_url().is_none()); + assert!(config.relay_secret().is_none()); + } + + #[test] + fn relay_secret_debug_output_is_redacted() { + let secret = RelaySecret::from_hex(&"a".repeat(64)).expect("secret"); + + assert_eq!(format!("{secret:?}"), "RelaySecret(<redacted>)"); + assert_eq!(secret.redacted(), "<redacted>"); + assert_eq!(secret.expose_for_signing(), "a".repeat(64)); + } + + #[test] + fn relay_identity_validation_is_strict() { + assert_eq!( + RelaySecret::from_hex(&"A".repeat(64)) + .expect_err("error") + .message(), + "groups.relay_secret must be lowercase hex" + ); + assert_eq!( + CanonicalRelayUrl::new(" wss://relay.radroots.test") + .expect_err("error") + .message(), + "groups.canonical_relay_url must not contain leading or trailing whitespace" + ); + assert_eq!( + CanonicalRelayUrl::new("https://relay.radroots.test") + .expect_err("error") + .message(), + "groups.canonical_relay_url must start with ws:// or wss://" + ); + } + + #[test] + fn limits_reject_zero_values() { + let error = GroupLimitsConfig::new(0, 1, 1, 1, 1).expect_err("error"); + + assert_eq!( + error.message(), + "groups.limits.max_group_id_bytes must be greater than zero" + ); + } +} diff --git a/crates/tangle_store_pocket/Cargo.toml b/crates/tangle_store_pocket/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tangle_store_pocket" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Pocket storage boundary for tangle" + +[dependencies] + +[lints] +workspace = true diff --git a/crates/tangle_store_pocket/src/lib.rs b/crates/tangle_store_pocket/src/lib.rs @@ -0,0 +1,152 @@ +#![forbid(unsafe_code)] + +use core::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PocketSyncPolicy { + FlushOnWrite, + FlushOnShutdown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PocketStoreConfig { + data_directory: PathBuf, + map_size_bytes: u64, + reader_slots: u32, + sync_policy: PocketSyncPolicy, +} + +impl PocketStoreConfig { + pub fn new( + data_directory: impl Into<PathBuf>, + map_size_bytes: u64, + reader_slots: u32, + sync_policy: PocketSyncPolicy, + ) -> Result<Self, PocketConfigError> { + let config = Self { + data_directory: data_directory.into(), + map_size_bytes, + reader_slots, + sync_policy, + }; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<(), PocketConfigError> { + if self.data_directory.as_os_str().is_empty() { + return Err(PocketConfigError::invalid( + "pocket.data_directory must not be empty", + )); + } + if self.map_size_bytes == 0 { + return Err(PocketConfigError::invalid( + "pocket.map_size_bytes must be greater than zero", + )); + } + if self.reader_slots == 0 { + return Err(PocketConfigError::invalid( + "pocket.reader_slots must be greater than zero", + )); + } + Ok(()) + } + + pub fn data_directory(&self) -> &Path { + &self.data_directory + } + + pub fn map_size_bytes(&self) -> u64 { + self.map_size_bytes + } + + pub fn reader_slots(&self) -> u32 { + self.reader_slots + } + + pub fn sync_policy(&self) -> PocketSyncPolicy { + self.sync_policy + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PocketConfigError { + message: String, +} + +impl PocketConfigError { + pub fn invalid(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for PocketConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for PocketConfigError {} + +#[cfg(test)] +mod tests { + use super::{PocketStoreConfig, PocketSyncPolicy}; + + #[test] + fn pocket_store_config_preserves_explicit_storage_boundary() { + let config = PocketStoreConfig::new( + "runtime/radroots/tangle/pocket", + 1024 * 1024 * 1024, + 128, + PocketSyncPolicy::FlushOnShutdown, + ) + .expect("config"); + + assert_eq!( + config.data_directory().to_string_lossy(), + "runtime/radroots/tangle/pocket" + ); + assert_eq!(config.map_size_bytes(), 1024 * 1024 * 1024); + assert_eq!(config.reader_slots(), 128); + assert_eq!(config.sync_policy(), PocketSyncPolicy::FlushOnShutdown); + } + + #[test] + fn pocket_store_config_rejects_implicit_storage_values() { + assert_eq!( + PocketStoreConfig::new("", 1, 1, PocketSyncPolicy::FlushOnWrite) + .expect_err("error") + .message(), + "pocket.data_directory must not be empty" + ); + assert_eq!( + PocketStoreConfig::new( + "runtime/radroots/tangle/pocket", + 0, + 1, + PocketSyncPolicy::FlushOnWrite + ) + .expect_err("error") + .message(), + "pocket.map_size_bytes must be greater than zero" + ); + assert_eq!( + PocketStoreConfig::new( + "runtime/radroots/tangle/pocket", + 1, + 0, + PocketSyncPolicy::FlushOnWrite + ) + .expect_err("error") + .message(), + "pocket.reader_slots must be greater than zero" + ); + } +}