diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3..7108c29087 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -36,6 +36,8 @@ pub mod pairing; pub mod presence; /// NIP-PMA owner-encrypted private managed-agent wire codec. pub mod private_managed_agent; +/// NIP-PC canonical relay projection templates for collaborative Project state. +pub mod project_state; /// Canonical relay runtime identities. pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. diff --git a/crates/buzz-core/src/project_state.rs b/crates/buzz-core/src/project_state.rs new file mode 100644 index 0000000000..8c94e2600f --- /dev/null +++ b/crates/buzz-core/src/project_state.rs @@ -0,0 +1,496 @@ +//! Pure canonical serializer for NIP-PC Project State projections. + +use std::collections::HashSet; + +use nostr::{Event, EventId, Kind, Tag}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +use crate::kind::{KIND_PROJECT, KIND_PROJECT_STATE}; + +const PROJECT_D_MAX: usize = 1024; +const MEMBER_CAP: usize = 64; + +/// Inputs needed to derive a canonical relay-signed Project State event body. +#[derive(Debug, Clone, Copy)] +pub struct ProjectStateProjectionInput<'a> { + /// Canonical `30621::` Project coordinate. + pub coordinate: &'a str, + /// Monotonic authoritative Project revision. + pub revision: i64, + /// Current owner-signed NIP-MP Project identity event. + pub identity_event: &'a Event, + /// Command or lifecycle event that produced this revision. + pub change_event_id: &'a EventId, + /// Whether the Project is currently deleted. + pub deleted: bool, + /// Authoritative related-channel set. + pub related_channels: &'a [Uuid], +} + +/// Unsigned, untimestamped fields for a relay-authored Project State event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectStateTemplate { + /// Fixed NIP-PC Project State kind (`30623`). + pub kind: Kind, + /// Canonically ordered event tags. + pub tags: Vec, + /// Stable compact JSON projection content. + pub content: String, +} + +/// A structural or canonicalization failure while deriving Project State. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProjectStateError { + /// The Project coordinate is not canonical or does not match the identity. + #[error("invalid Project coordinate: {0}")] + Coordinate(String), + /// The owner identity is not a valid NIP-MP Project event. + #[error("invalid Project identity: {0}")] + Identity(String), + /// The relational revision or related-channel set is invalid. + #[error("invalid Project state: {0}")] + State(String), + /// An output tag or JSON body could not be encoded. + #[error("could not encode Project State: {0}")] + Encoding(String), +} + +/// Derive the canonical unsigned NIP-PC Project State event template. +/// +/// Signing and strictly monotonic `created_at` allocation remain relay concerns. +pub fn project_state_template( + input: ProjectStateProjectionInput<'_>, +) -> Result { + let (owner, project_d) = parse_project_coordinate(input.coordinate)?; + if input.revision < 1 { + return Err(ProjectStateError::State( + "revision must be a positive signed 64-bit integer".into(), + )); + } + validate_identity(input.identity_event, owner, project_d)?; + + let mut related: Vec = input.related_channels.iter().map(Uuid::to_string).collect(); + related.sort_unstable(); + related.dedup(); + if related.len() != input.related_channels.len() { + return Err(ProjectStateError::State( + "related channels must not contain duplicates".into(), + )); + } + if related.len() > MEMBER_CAP { + return Err(ProjectStateError::State(format!( + "related channels exceed the {MEMBER_CAP}-channel cap" + ))); + } + + let project_tags = if input.deleted { + Vec::new() + } else { + canonical_live_tags(input.identity_event, project_d, &related)? + }; + let body = ProjectionBody { + v: 1, + deleted: input.deleted, + project_tags, + }; + let content = serde_json::to_string(&body) + .map_err(|error| ProjectStateError::Encoding(error.to_string()))?; + + let projection_d = hex::encode(Sha256::digest(input.coordinate.as_bytes())); + let tags = vec![ + make_tag(vec!["d".into(), projection_d])?, + make_tag(vec!["a".into(), input.coordinate.into()])?, + make_tag(vec!["rev".into(), input.revision.to_string()])?, + make_tag(vec![ + "e".into(), + input.identity_event.id.to_hex(), + String::new(), + "identity".into(), + ])?, + make_tag(vec![ + "e".into(), + input.change_event_id.to_hex(), + String::new(), + "change".into(), + ])?, + ]; + + Ok(ProjectStateTemplate { + kind: Kind::from(KIND_PROJECT_STATE as u16), + tags, + content, + }) +} + +#[derive(Serialize)] +struct ProjectionBody { + v: u8, + deleted: bool, + project_tags: Vec>, +} + +fn parse_project_coordinate(coordinate: &str) -> Result<(&str, &str), ProjectStateError> { + let mut parts = coordinate.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let project_d = parts.next(); + if kind != Some("30621") { + return Err(ProjectStateError::Coordinate("kind must be 30621".into())); + } + let owner = owner.ok_or_else(|| ProjectStateError::Coordinate("owner is missing".into()))?; + if owner.len() != 64 + || !owner + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProjectStateError::Coordinate( + "owner must be 64 lowercase hexadecimal characters".into(), + )); + } + let project_d = project_d + .filter(|value| !value.is_empty() && value.len() <= PROJECT_D_MAX) + .ok_or_else(|| { + ProjectStateError::Coordinate(format!( + "Project d must contain 1..={PROJECT_D_MAX} bytes" + )) + })?; + Ok((owner, project_d)) +} + +fn validate_identity(event: &Event, owner: &str, project_d: &str) -> Result<(), ProjectStateError> { + if event.kind.as_u16() as u32 != KIND_PROJECT { + return Err(ProjectStateError::Identity( + "event kind must be 30621".into(), + )); + } + if event.pubkey.to_hex() != owner { + return Err(ProjectStateError::Identity( + "event signer does not match the coordinate owner".into(), + )); + } + + let d_tags: Vec<&[String]> = event + .tags + .iter() + .map(Tag::as_slice) + .filter(|parts| parts.first().is_some_and(|name| name == "d")) + .collect(); + if d_tags.len() != 1 || d_tags[0].get(1).map(String::as_str) != Some(project_d) { + return Err(ProjectStateError::Identity( + "event must have exactly one d tag matching the coordinate".into(), + )); + } + + validate_nip_mp_tags(event) +} + +fn validate_nip_mp_tags(event: &Event) -> Result<(), ProjectStateError> { + let mut members = Vec::new(); + let mut singleton_counts = [0usize; 4]; + let singleton_names = ["name", "description", "buzz-channel", "buzz-visibility"]; + let singleton_limits = [256usize, 2048, 256, 256]; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(name) = parts.first().map(String::as_str) else { + continue; + }; + if name == "a" { + if !(2..=3).contains(&parts.len()) { + return Err(ProjectStateError::Identity( + "member a tags must have two or three elements".into(), + )); + } + members.push(parts[1].as_str()); + } else if let Some(index) = singleton_names.iter().position(|known| known == &name) { + singleton_counts[index] += 1; + if parts.get(1).map_or(0, String::len) > singleton_limits[index] { + return Err(ProjectStateError::Identity(format!( + "{name} exceeds its NIP-MP byte limit" + ))); + } + } + } + if members.len() > MEMBER_CAP { + return Err(ProjectStateError::Identity( + "member a tags exceed the 64-member cap".into(), + )); + } + if singleton_counts.iter().any(|count| *count > 1) { + return Err(ProjectStateError::Identity( + "NIP-MP singleton metadata tag is duplicated".into(), + )); + } + let mut seen = HashSet::with_capacity(members.len()); + for coordinate in members { + validate_member_coordinate(coordinate)?; + if !seen.insert(coordinate) { + return Err(ProjectStateError::Identity( + "member repository coordinate is duplicated".into(), + )); + } + } + Ok(()) +} + +fn validate_member_coordinate(coordinate: &str) -> Result<(), ProjectStateError> { + let mut parts = coordinate.splitn(3, ':'); + let valid = parts.next() == Some("30617") + && parts.next().is_some_and(|owner| { + owner.len() == 64 + && owner + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + && parts.next().is_some_and(|repo_d| !repo_d.is_empty()); + if !valid { + return Err(ProjectStateError::Identity( + "member repository coordinate is malformed".into(), + )); + } + Ok(()) +} + +fn canonical_live_tags( + identity: &Event, + project_d: &str, + related: &[String], +) -> Result>, ProjectStateError> { + let mut name = None; + let mut description = None; + let mut members = Vec::new(); + let mut home_channel = None; + let mut visibility = None; + let mut extensions = Vec::new(); + + for tag in identity.tags.iter() { + let parts = tag.as_slice().to_vec(); + match parts.first().map(String::as_str) { + Some("d") => {} + Some("name") => name = Some(parts), + Some("description") => description = Some(parts), + Some("a") => members.push(parts), + Some("buzz-channel") => home_channel = Some(parts), + Some("buzz-visibility") => visibility = Some(parts), + Some("auth" | "buzz-related-channel") => {} + _ => extensions.push(parts), + } + } + members.sort_unstable(); + if let Some(home) = home_channel + .as_ref() + .and_then(|tag| tag.get(1)) + .and_then(|value| Uuid::parse_str(value).ok()) + { + if related.iter().any(|channel| channel == &home.to_string()) { + return Err(ProjectStateError::State( + "the home channel cannot also be a related channel".into(), + )); + } + } + + let mut tags = vec![vec!["d".into(), project_d.into()]]; + tags.extend(name); + tags.extend(description); + tags.extend(members); + tags.extend(home_channel); + tags.extend( + related + .iter() + .map(|channel| vec!["buzz-related-channel".into(), channel.clone()]), + ); + tags.extend(visibility); + tags.extend(extensions); + Ok(tags) +} + +fn make_tag(parts: Vec) -> Result { + Tag::parse(parts).map_err(|error| ProjectStateError::Encoding(error.to_string())) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Tag}; + + use super::*; + + fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).expect("valid test tag") + } + + fn fixture(tags: Vec) -> (Keys, Event) { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::from(KIND_PROJECT as u16), "ignored") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign test identity"); + (keys, event) + } + + #[test] + fn emits_exact_canonical_live_projection() { + let repo_b = format!("30617:{}:b", "b".repeat(64)); + let repo_a = format!("30617:{}:a", "a".repeat(64)); + let home = "11111111-1111-4111-8111-111111111111"; + let related_a = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + let related_b = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap(); + let (keys, identity) = fixture(vec![ + tag(&["x-ext", "one", "unchanged"]), + tag(&["a", &repo_b, "wss://b.example"]), + tag(&["auth", "secret"]), + tag(&["description", "Desc"]), + tag(&["d", "project:one"]), + tag(&[ + "buzz-related-channel", + "44444444-4444-4444-8444-444444444444", + ]), + tag(&["name", "Name"]), + tag(&["a", &repo_a]), + tag(&["buzz-visibility", "unlisted"]), + tag(&["buzz-channel", home]), + tag(&["z-ext", "two"]), + ]); + let change = EventBuilder::new(Kind::TextNote, "change") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let coordinate = format!("30621:{}:project:one", keys.public_key().to_hex()); + + let template = project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 8, + identity_event: &identity, + change_event_id: &change.id, + deleted: false, + related_channels: &[related_b, related_a], + }) + .unwrap(); + + let expected_d = hex::encode(Sha256::digest(coordinate.as_bytes())); + let raw_tags: Vec> = template + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + assert_eq!(template.kind.as_u16() as u32, KIND_PROJECT_STATE); + assert_eq!( + raw_tags, + vec![ + vec!["d".into(), expected_d], + vec!["a".into(), coordinate], + vec!["rev".into(), "8".into()], + vec![ + "e".into(), + identity.id.to_hex(), + "".into(), + "identity".into() + ], + vec!["e".into(), change.id.to_hex(), "".into(), "change".into()], + ] + ); + assert_eq!( + template.content, + format!( + "{{\"v\":1,\"deleted\":false,\"project_tags\":[[\"d\",\"project:one\"],[\"name\",\"Name\"],[\"description\",\"Desc\"],[\"a\",\"{repo_a}\"],[\"a\",\"{repo_b}\",\"wss://b.example\"],[\"buzz-channel\",\"{home}\"],[\"buzz-related-channel\",\"{related_a}\"],[\"buzz-related-channel\",\"{related_b}\"],[\"buzz-visibility\",\"unlisted\"],[\"x-ext\",\"one\",\"unchanged\"],[\"z-ext\",\"two\"]]}}" + ) + ); + } + + #[test] + fn emits_exact_tombstone_content() { + let (keys, identity) = fixture(vec![tag(&["d", "gone"])]); + let coordinate = format!("30621:{}:gone", keys.public_key().to_hex()); + let template = project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 2, + identity_event: &identity, + change_event_id: &identity.id, + deleted: true, + related_channels: &[], + }) + .unwrap(); + assert_eq!( + template.content, + "{\"v\":1,\"deleted\":true,\"project_tags\":[]}" + ); + } + + #[test] + fn hashes_max_length_colon_bearing_project_d() { + let project_d = format!("prefix:{}", "x".repeat(PROJECT_D_MAX - 7)); + assert_eq!(project_d.len(), PROJECT_D_MAX); + let (keys, identity) = fixture(vec![tag(&["d", &project_d])]); + let coordinate = format!("30621:{}:{project_d}", keys.public_key().to_hex()); + + let template = project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 1, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[], + }) + .unwrap(); + + assert_eq!( + template.tags[0].as_slice(), + [ + "d", + hex::encode(Sha256::digest(coordinate.as_bytes())).as_str() + ] + ); + } + + #[test] + fn rejects_home_channel_as_related() { + let home = "11111111-1111-4111-8111-111111111111"; + let (keys, identity) = fixture(vec![tag(&["d", "project"]), tag(&["buzz-channel", home])]); + let coordinate = format!("30621:{}:project", keys.public_key().to_hex()); + let home = Uuid::parse_str(home).unwrap(); + + assert!(matches!( + project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 1, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[home], + }), + Err(ProjectStateError::State(_)) + )); + } + + #[test] + fn rejects_mismatched_identity_and_noncanonical_state() { + let (keys, identity) = fixture(vec![tag(&["d", "project"])]); + let wrong_coordinate = format!("30621:{}:other", keys.public_key().to_hex()); + let duplicate = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + + assert!(matches!( + project_state_template(ProjectStateProjectionInput { + coordinate: &wrong_coordinate, + revision: 1, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[], + }), + Err(ProjectStateError::Identity(_)) + )); + let coordinate = format!("30621:{}:project", keys.public_key().to_hex()); + assert!(matches!( + project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 1, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[duplicate, duplicate], + }), + Err(ProjectStateError::State(_)) + )); + } +}