lib.rs (19990B)
1 #![forbid(unsafe_code)] 2 3 pub mod classification; 4 pub mod errors; 5 pub mod event_view; 6 pub mod ids; 7 pub mod kinds; 8 pub mod metadata; 9 pub mod outbox; 10 pub mod policy; 11 pub mod projection; 12 pub mod read_gate; 13 pub mod roles; 14 pub mod signing; 15 pub mod tags; 16 pub mod write_gate; 17 18 use core::fmt; 19 use serde::Deserialize; 20 use tangle_protocol::PublicKeyHex; 21 22 pub use classification::{GroupEventClass, classify_group_event}; 23 pub use errors::{GroupError, GroupErrorKind, GroupReplyPrefix}; 24 pub use event_view::{GroupEventTag, GroupEventView}; 25 pub use ids::GroupId; 26 pub use kinds::{ 27 KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_EVENT, 28 KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, 29 KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, 30 KIND_GROUP_REMOVE_USER, KIND_GROUP_ROLES, KIND_GROUP_STATE_39004, NIP29_GROUP_KIND_VALUES, 31 NIP29_MODERATION_KIND_VALUES, NIP29_RELAY_GENERATED_KIND_VALUES, 32 NIP29_USER_REQUEST_KIND_VALUES, 33 }; 34 pub use metadata::{ 35 GroupMetadata, GroupMetadataFlags, GroupMetadataText, SupportedKinds, parse_group_metadata, 36 }; 37 pub use outbox::{ 38 GroupCrashHooks, GroupCrashPoint, GroupOutbox, GroupOutboxEffect, GroupOutboxKey, 39 GroupOutboxPayload, GroupOutboxRecord, GroupOutboxStatus, OutboxRecoveryReadiness, 40 OutboxReplayPlan, 41 }; 42 pub use policy::{ 43 GroupAuthority, GroupWriteDecision, GroupWritePolicy, non_enumerating_group_error, 44 }; 45 pub use projection::{ 46 CanonicalGroupEvent, GROUP_POLICY_VERSION, GROUP_PROJECTION_SCHEMA_VERSION, GroupEventDeletion, 47 GroupLifecycleState, GroupProjection, GroupRecoveryReadiness, GroupSnapshotIds, GroupState, 48 GroupTombstone, MemberState, MemberStatus, ProjectedRoleDefinition, ProjectionApplyOutcome, 49 ProjectionCheckpoint, ProjectionOrderTuple, ProjectionRebuildReport, StoreOffset, 50 event_deletion_key, group_current_key, member_current_key, projection_checkpoint_key, 51 rebuild_group_projection, role_current_key, tombstone_key, 52 }; 53 pub use read_gate::{GroupReadDecision, GroupReadGate}; 54 pub use roles::{ 55 Capability, CapabilitySet, PERMANENT_RELAY_OVERRIDE_ROLE, RoleDefinition, RoleName, 56 resolve_capabilities, 57 }; 58 pub use signing::GroupGeneratedEventBuilder; 59 pub use tags::{GroupTag, GroupTagName, extract_group_tag, has_group_identity_tag}; 60 pub use write_gate::{ 61 GroupAuthContext, require_group_auth_as_author, validate_client_group_event_structure, 62 }; 63 64 #[derive(Clone, PartialEq, Eq)] 65 pub struct RelaySecret(String); 66 67 impl RelaySecret { 68 pub const HEX_LENGTH: usize = 64; 69 70 pub fn from_hex(value: &str) -> Result<Self, GroupConfigError> { 71 require_lowercase_hex("groups.relay_secret", value, Self::HEX_LENGTH)?; 72 Ok(Self(value.to_owned())) 73 } 74 75 pub fn expose_for_signing(&self) -> &str { 76 &self.0 77 } 78 79 pub fn redacted(&self) -> &'static str { 80 "<redacted>" 81 } 82 } 83 84 impl fmt::Debug for RelaySecret { 85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 86 formatter.write_str("RelaySecret(<redacted>)") 87 } 88 } 89 90 #[derive(Debug, Clone, PartialEq, Eq)] 91 pub struct CanonicalRelayUrl(String); 92 93 impl CanonicalRelayUrl { 94 pub fn new(value: &str) -> Result<Self, GroupConfigError> { 95 if value.is_empty() { 96 return Err(GroupConfigError::invalid( 97 "groups.canonical_relay_url is required", 98 )); 99 } 100 if value.trim() != value { 101 return Err(GroupConfigError::invalid( 102 "groups.canonical_relay_url must not contain leading or trailing whitespace", 103 )); 104 } 105 if !(value.starts_with("ws://") || value.starts_with("wss://")) { 106 return Err(GroupConfigError::invalid( 107 "groups.canonical_relay_url must start with ws:// or wss://", 108 )); 109 } 110 Ok(Self(value.to_owned())) 111 } 112 113 pub fn as_str(&self) -> &str { 114 &self.0 115 } 116 } 117 118 impl fmt::Display for CanonicalRelayUrl { 119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 120 formatter.write_str(self.as_str()) 121 } 122 } 123 124 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] 125 #[serde(deny_unknown_fields)] 126 pub struct GroupPolicyConfig { 127 #[serde(default)] 128 public_join: bool, 129 #[serde(default)] 130 invites_enabled: bool, 131 } 132 133 impl GroupPolicyConfig { 134 pub fn strict() -> Self { 135 Self { 136 public_join: false, 137 invites_enabled: false, 138 } 139 } 140 141 pub fn new(public_join: bool, invites_enabled: bool) -> Result<Self, GroupConfigError> { 142 let value = Self { 143 public_join, 144 invites_enabled, 145 }; 146 value.validate()?; 147 Ok(value) 148 } 149 150 pub fn validate(&self) -> Result<(), GroupConfigError> { 151 if self.invites_enabled { 152 return Err(GroupConfigError::invalid( 153 "groups.policy.invites_enabled is not supported until invite flow is implemented", 154 )); 155 } 156 Ok(()) 157 } 158 159 pub fn public_join(&self) -> bool { 160 self.public_join 161 } 162 163 pub fn invites_enabled(&self) -> bool { 164 self.invites_enabled 165 } 166 } 167 168 impl Default for GroupPolicyConfig { 169 fn default() -> Self { 170 Self::strict() 171 } 172 } 173 174 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 175 pub struct GroupRuntimeSettingsConfig { 176 policy: GroupPolicyConfig, 177 limits: GroupLimitsConfig, 178 } 179 180 impl GroupRuntimeSettingsConfig { 181 pub fn strict() -> Self { 182 Self { 183 policy: GroupPolicyConfig::strict(), 184 limits: GroupLimitsConfig::default(), 185 } 186 } 187 188 pub fn new( 189 policy: GroupPolicyConfig, 190 limits: GroupLimitsConfig, 191 ) -> Result<Self, GroupConfigError> { 192 let value = Self { policy, limits }; 193 value.validate()?; 194 Ok(value) 195 } 196 197 pub fn validate(&self) -> Result<(), GroupConfigError> { 198 self.policy.validate()?; 199 self.limits.validate() 200 } 201 202 pub fn policy(&self) -> GroupPolicyConfig { 203 self.policy 204 } 205 206 pub fn limits(&self) -> GroupLimitsConfig { 207 self.limits 208 } 209 } 210 211 impl Default for GroupRuntimeSettingsConfig { 212 fn default() -> Self { 213 Self::strict() 214 } 215 } 216 217 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] 218 #[serde(deny_unknown_fields)] 219 pub struct GroupLimitsConfig { 220 #[serde(default = "default_max_group_id_bytes")] 221 max_group_id_bytes: u16, 222 #[serde(default = "default_max_group_tags_per_event")] 223 max_group_tags_per_event: u16, 224 #[serde(default = "default_max_supported_kinds")] 225 max_supported_kinds: u16, 226 #[serde(default = "default_max_member_list_pubkeys")] 227 max_member_list_pubkeys: u32, 228 #[serde(default = "default_max_outbox_replay_batch")] 229 max_outbox_replay_batch: u32, 230 } 231 232 impl GroupLimitsConfig { 233 pub fn new( 234 max_group_id_bytes: u16, 235 max_group_tags_per_event: u16, 236 max_supported_kinds: u16, 237 max_member_list_pubkeys: u32, 238 max_outbox_replay_batch: u32, 239 ) -> Result<Self, GroupConfigError> { 240 let value = Self { 241 max_group_id_bytes, 242 max_group_tags_per_event, 243 max_supported_kinds, 244 max_member_list_pubkeys, 245 max_outbox_replay_batch, 246 }; 247 value.validate()?; 248 Ok(value) 249 } 250 251 pub fn validate(&self) -> Result<(), GroupConfigError> { 252 require_positive("groups.limits.max_group_id_bytes", self.max_group_id_bytes)?; 253 require_positive( 254 "groups.limits.max_group_tags_per_event", 255 self.max_group_tags_per_event, 256 )?; 257 require_positive( 258 "groups.limits.max_supported_kinds", 259 self.max_supported_kinds, 260 )?; 261 require_positive( 262 "groups.limits.max_member_list_pubkeys", 263 self.max_member_list_pubkeys, 264 )?; 265 require_positive( 266 "groups.limits.max_outbox_replay_batch", 267 self.max_outbox_replay_batch, 268 )?; 269 Ok(()) 270 } 271 272 pub fn max_group_id_bytes(&self) -> u16 { 273 self.max_group_id_bytes 274 } 275 276 pub fn max_group_tags_per_event(&self) -> u16 { 277 self.max_group_tags_per_event 278 } 279 280 pub fn max_supported_kinds(&self) -> u16 { 281 self.max_supported_kinds 282 } 283 284 pub fn max_member_list_pubkeys(&self) -> u32 { 285 self.max_member_list_pubkeys 286 } 287 288 pub fn max_outbox_replay_batch(&self) -> u32 { 289 self.max_outbox_replay_batch 290 } 291 } 292 293 impl Default for GroupLimitsConfig { 294 fn default() -> Self { 295 Self { 296 max_group_id_bytes: default_max_group_id_bytes(), 297 max_group_tags_per_event: default_max_group_tags_per_event(), 298 max_supported_kinds: default_max_supported_kinds(), 299 max_member_list_pubkeys: default_max_member_list_pubkeys(), 300 max_outbox_replay_batch: default_max_outbox_replay_batch(), 301 } 302 } 303 } 304 305 #[derive(Debug, Clone, PartialEq, Eq)] 306 pub struct GroupRuntimeConfig { 307 enabled: bool, 308 canonical_relay_url: Option<CanonicalRelayUrl>, 309 relay_secret: Option<RelaySecret>, 310 owner_pubkeys: Vec<PublicKeyHex>, 311 admin_pubkeys: Vec<PublicKeyHex>, 312 settings: GroupRuntimeSettingsConfig, 313 } 314 315 impl GroupRuntimeConfig { 316 pub fn disabled() -> Self { 317 Self { 318 enabled: false, 319 canonical_relay_url: None, 320 relay_secret: None, 321 owner_pubkeys: Vec::new(), 322 admin_pubkeys: Vec::new(), 323 settings: GroupRuntimeSettingsConfig::default(), 324 } 325 } 326 327 pub fn new( 328 enabled: bool, 329 canonical_relay_url: Option<CanonicalRelayUrl>, 330 relay_secret: Option<RelaySecret>, 331 owner_pubkeys: Vec<PublicKeyHex>, 332 admin_pubkeys: Vec<PublicKeyHex>, 333 settings: GroupRuntimeSettingsConfig, 334 ) -> Result<Self, GroupConfigError> { 335 settings.validate()?; 336 if enabled && canonical_relay_url.is_none() { 337 return Err(GroupConfigError::invalid( 338 "groups.canonical_relay_url is required when groups are enabled", 339 )); 340 } 341 if enabled && relay_secret.is_none() { 342 return Err(GroupConfigError::invalid( 343 "groups.relay_secret is required when groups are enabled", 344 )); 345 } 346 Ok(Self { 347 enabled, 348 canonical_relay_url, 349 relay_secret, 350 owner_pubkeys, 351 admin_pubkeys, 352 settings, 353 }) 354 } 355 356 pub fn enabled(&self) -> bool { 357 self.enabled 358 } 359 360 pub fn canonical_relay_url(&self) -> Option<&CanonicalRelayUrl> { 361 self.canonical_relay_url.as_ref() 362 } 363 364 pub fn relay_secret(&self) -> Option<&RelaySecret> { 365 self.relay_secret.as_ref() 366 } 367 368 pub fn owner_pubkeys(&self) -> &[PublicKeyHex] { 369 &self.owner_pubkeys 370 } 371 372 pub fn admin_pubkeys(&self) -> &[PublicKeyHex] { 373 &self.admin_pubkeys 374 } 375 376 pub fn policy(&self) -> GroupPolicyConfig { 377 self.settings.policy() 378 } 379 380 pub fn limits(&self) -> GroupLimitsConfig { 381 self.settings.limits() 382 } 383 } 384 385 #[derive(Debug, Clone, PartialEq, Eq)] 386 pub struct GroupConfigError { 387 message: String, 388 } 389 390 impl GroupConfigError { 391 pub fn invalid(message: impl Into<String>) -> Self { 392 Self { 393 message: message.into(), 394 } 395 } 396 397 pub fn message(&self) -> &str { 398 &self.message 399 } 400 } 401 402 impl fmt::Display for GroupConfigError { 403 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 404 formatter.write_str(&self.message) 405 } 406 } 407 408 impl std::error::Error for GroupConfigError {} 409 410 #[derive(Debug, Deserialize)] 411 #[serde(deny_unknown_fields)] 412 struct GroupRuntimeConfigDocument { 413 enabled: bool, 414 canonical_relay_url: Option<String>, 415 relay_secret: Option<String>, 416 #[serde(default)] 417 owner_pubkeys: Vec<String>, 418 #[serde(default)] 419 admin_pubkeys: Vec<String>, 420 #[serde(default)] 421 policy: GroupPolicyConfig, 422 #[serde(default)] 423 limits: GroupLimitsConfig, 424 } 425 426 pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig, GroupConfigError> { 427 let document = serde_json::from_str::<GroupRuntimeConfigDocument>(raw).map_err(|error| { 428 GroupConfigError::invalid(format!("groups config JSON is invalid: {error}")) 429 })?; 430 let canonical_relay_url = document 431 .canonical_relay_url 432 .as_deref() 433 .map(CanonicalRelayUrl::new) 434 .transpose()?; 435 let relay_secret = document 436 .relay_secret 437 .as_deref() 438 .map(RelaySecret::from_hex) 439 .transpose()?; 440 GroupRuntimeConfig::new( 441 document.enabled, 442 canonical_relay_url, 443 relay_secret, 444 parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?, 445 parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?, 446 GroupRuntimeSettingsConfig::new(document.policy, document.limits)?, 447 ) 448 } 449 450 fn parse_pubkeys(field: &str, values: Vec<String>) -> Result<Vec<PublicKeyHex>, GroupConfigError> { 451 values 452 .into_iter() 453 .map(|value| { 454 PublicKeyHex::new(&value).map_err(|error| { 455 GroupConfigError::invalid(format!("{field} contains invalid pubkey: {error}")) 456 }) 457 }) 458 .collect() 459 } 460 461 fn require_lowercase_hex(field: &str, value: &str, length: usize) -> Result<(), GroupConfigError> { 462 if value.len() != length { 463 return Err(GroupConfigError::invalid(format!( 464 "{field} must be {length} lowercase hex characters" 465 ))); 466 } 467 if !value 468 .as_bytes() 469 .iter() 470 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) 471 { 472 return Err(GroupConfigError::invalid(format!( 473 "{field} must be lowercase hex" 474 ))); 475 } 476 Ok(()) 477 } 478 479 fn require_positive<T>(field: &str, value: T) -> Result<(), GroupConfigError> 480 where 481 T: Copy + PartialEq + From<u8> + fmt::Display, 482 { 483 if value == T::from(0) { 484 return Err(GroupConfigError::invalid(format!( 485 "{field} must be greater than zero" 486 ))); 487 } 488 Ok(()) 489 } 490 491 fn default_max_group_id_bytes() -> u16 { 492 128 493 } 494 495 fn default_max_group_tags_per_event() -> u16 { 496 8 497 } 498 499 fn default_max_supported_kinds() -> u16 { 500 512 501 } 502 503 fn default_max_member_list_pubkeys() -> u32 { 504 100_000 505 } 506 507 fn default_max_outbox_replay_batch() -> u32 { 508 1_000 509 } 510 511 #[cfg(test)] 512 mod tests { 513 use super::{ 514 CanonicalRelayUrl, GroupLimitsConfig, GroupPolicyConfig, RelaySecret, 515 parse_group_runtime_config_json, 516 }; 517 518 #[test] 519 fn enabled_group_config_requires_relay_identity_material() { 520 let error = parse_group_runtime_config_json(r#"{"enabled": true}"#).expect_err("error"); 521 522 assert_eq!( 523 error.message(), 524 "groups.canonical_relay_url is required when groups are enabled" 525 ); 526 } 527 528 #[test] 529 fn enabled_group_config_parses_relay_identity_limits_and_flags() { 530 let owner = "1".repeat(64); 531 let admin = "2".repeat(64); 532 let secret = "3".repeat(64); 533 let raw = format!( 534 r#"{{ 535 "enabled": true, 536 "canonical_relay_url": "wss://relay.radroots.test", 537 "relay_secret": "{secret}", 538 "owner_pubkeys": ["{owner}"], 539 "admin_pubkeys": ["{admin}"], 540 "policy": {{"public_join": false, "invites_enabled": false}}, 541 "limits": {{ 542 "max_group_id_bytes": 64, 543 "max_group_tags_per_event": 4, 544 "max_supported_kinds": 32, 545 "max_member_list_pubkeys": 500, 546 "max_outbox_replay_batch": 25 547 }} 548 }}"# 549 ); 550 551 let config = parse_group_runtime_config_json(&raw).expect("config"); 552 553 assert!(config.enabled()); 554 assert_eq!( 555 config.canonical_relay_url().expect("url").as_str(), 556 "wss://relay.radroots.test" 557 ); 558 assert_eq!(config.owner_pubkeys().len(), 1); 559 assert_eq!(config.admin_pubkeys().len(), 1); 560 assert_eq!(config.policy(), GroupPolicyConfig::strict()); 561 assert!(!config.policy().public_join()); 562 assert!(!config.policy().invites_enabled()); 563 assert_eq!(config.limits().max_group_id_bytes(), 64); 564 assert_eq!(config.limits().max_group_tags_per_event(), 4); 565 assert_eq!(config.limits().max_supported_kinds(), 32); 566 assert_eq!(config.limits().max_member_list_pubkeys(), 500); 567 assert_eq!(config.limits().max_outbox_replay_batch(), 25); 568 } 569 570 #[test] 571 fn disabled_group_config_does_not_require_relay_secret() { 572 let config = parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("config"); 573 574 assert!(!config.enabled()); 575 assert!(config.canonical_relay_url().is_none()); 576 assert!(config.relay_secret().is_none()); 577 assert_eq!(config.policy(), GroupPolicyConfig::strict()); 578 } 579 580 #[test] 581 fn group_policy_rejects_enabled_invites_until_invite_flow_exists() { 582 let error = parse_group_runtime_config_json( 583 r#"{"enabled": false, "policy": {"invites_enabled": true}}"#, 584 ) 585 .expect_err("invites"); 586 587 assert_eq!( 588 error.message(), 589 "groups.policy.invites_enabled is not supported until invite flow is implemented" 590 ); 591 } 592 593 #[test] 594 fn group_policy_rejects_compatibility_fields() { 595 let error = parse_group_runtime_config_json( 596 r#"{"enabled": false, "policy": {"compat_closed_means_restricted": true}}"#, 597 ) 598 .expect_err("compat"); 599 600 assert!( 601 error 602 .message() 603 .contains("unknown field `compat_closed_means_restricted`") 604 ); 605 } 606 607 #[test] 608 fn group_config_rejects_removed_and_unknown_fields() { 609 let removed_redaction = parse_group_runtime_config_json( 610 r#"{"enabled": false, "redaction": {"redact_private_tags": true}}"#, 611 ) 612 .expect_err("redaction"); 613 assert!( 614 removed_redaction 615 .message() 616 .contains("unknown field `redaction`") 617 ); 618 619 let unknown_limit = parse_group_runtime_config_json( 620 r#"{"enabled": false, "limits": {"max_unimplemented_outbox_batch": 10}}"#, 621 ) 622 .expect_err("unknown limit"); 623 assert!( 624 unknown_limit 625 .message() 626 .contains("unknown field `max_unimplemented_outbox_batch`") 627 ); 628 } 629 630 #[test] 631 fn relay_secret_debug_output_is_redacted() { 632 let secret = RelaySecret::from_hex(&"a".repeat(64)).expect("secret"); 633 634 assert_eq!(format!("{secret:?}"), "RelaySecret(<redacted>)"); 635 assert_eq!(secret.redacted(), "<redacted>"); 636 assert_eq!(secret.expose_for_signing(), "a".repeat(64)); 637 } 638 639 #[test] 640 fn relay_identity_validation_is_strict() { 641 assert_eq!( 642 RelaySecret::from_hex(&"A".repeat(64)) 643 .expect_err("error") 644 .message(), 645 "groups.relay_secret must be lowercase hex" 646 ); 647 assert_eq!( 648 CanonicalRelayUrl::new(" wss://relay.radroots.test") 649 .expect_err("error") 650 .message(), 651 "groups.canonical_relay_url must not contain leading or trailing whitespace" 652 ); 653 assert_eq!( 654 CanonicalRelayUrl::new("https://relay.radroots.test") 655 .expect_err("error") 656 .message(), 657 "groups.canonical_relay_url must start with ws:// or wss://" 658 ); 659 } 660 661 #[test] 662 fn limits_reject_zero_values() { 663 let error = GroupLimitsConfig::new(0, 1, 1, 1, 1).expect_err("error"); 664 665 assert_eq!( 666 error.message(), 667 "groups.limits.max_group_id_bytes must be greater than zero" 668 ); 669 } 670 }