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:
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"
+ );
+ }
+}