diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 4b7257aba7..5ecad1d343 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -172,6 +172,31 @@ buzz channels unarchive --channel "$CHANNEL_ID" | jq . # Expected: {"event_id":"...","accepted":true,"message":"..."} ``` +#### 6.1.1 User Groups + +```bash +MEMBER_PK="0000000000000000000000000000000000000000000000000000000000000001" + +GROUP_ID=$(buzz groups create --handle cli-team --name "CLI Team" | jq -r '.group_id') +buzz groups list | jq . +buzz --format compact groups get cli-team | jq . + +buzz groups edit cli-team --description "CLI group test" \ + --default-channel "$CHANNEL_ID" | jq . +buzz groups add-members cli-team --member "$MEMBER_PK" | jq . +buzz groups remove-members "$GROUP_ID" --member "$MEMBER_PK" | jq . + +# Bulk channel addition resolves the current 39100 snapshot. +buzz groups add-members cli-team --member "$MEMBER_PK" | jq . +buzz channels add-group --channel "$CHANNEL_ID" --group cli-team | jq . + +# The message carries a group marker and p-tags only for group members +# who are currently in the channel. +buzz messages send --channel "$CHANNEL_ID" --content "hello @cli-team" | jq . + +buzz groups delete "$GROUP_ID" | jq . +``` + ### 6.2 Canvas ```bash diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..e718d09907 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -959,6 +959,19 @@ pub async fn cmd_add_channel_member( pubkey: &str, role: Option<&str>, ) -> Result<(), CliError> { + println!( + "{}", + add_channel_member(client, channel_id, pubkey, role).await? + ); + Ok(()) +} + +async fn add_channel_member( + client: &BuzzClient, + channel_id: &str, + pubkey: &str, + role: Option<&str>, +) -> Result { validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; @@ -980,8 +993,109 @@ pub async fn cmd_add_channel_member( let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); - Ok(()) + Ok(normalize_write_response(&resp)) +} + +pub async fn cmd_add_group_to_channel( + client: &BuzzClient, + channel_id: &str, + group_reference: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let group = crate::commands::groups::resolve_group(client, group_reference).await?; + let filter = serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("failed to parse channel members: {error}")))?; + let member_snapshot = events.first().ok_or_else(|| { + CliError::NotFound(format!("channel membership for '{channel_id}' not found")) + })?; + let existing_members: HashSet = extract_p_tags(member_snapshot) + .into_iter() + .filter_map(|member| { + member + .get("pubkey") + .and_then(serde_json::Value::as_str) + .map(str::to_ascii_lowercase) + }) + .collect(); + + let mut responses = Vec::with_capacity(group.members.len()); + let mut failure_count = 0; + for pubkey in &group.members { + if existing_members.contains(&pubkey.to_ascii_lowercase()) { + responses.push(serde_json::json!({ + "pubkey": pubkey, + "accepted": true, + "skipped": true, + "message": "already a channel member", + })); + continue; + } + + match add_channel_member(client, channel_id, pubkey, None).await { + Ok(response) => match serde_json::from_str::(&response) { + Ok(serde_json::Value::Object(mut result)) => { + let accepted = result + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + result.insert( + "pubkey".to_string(), + serde_json::Value::String(pubkey.clone()), + ); + if !accepted { + failure_count += 1; + let message = result + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("relay rejected member add") + .to_string(); + result.insert("error".to_string(), serde_json::Value::String(message)); + } + responses.push(serde_json::Value::Object(result)); + } + Ok(result) => { + failure_count += 1; + responses.push(serde_json::json!({ + "pubkey": pubkey, + "accepted": false, + "error": "relay returned an invalid member-add response", + "response": result, + })); + } + Err(error) => { + failure_count += 1; + responses.push(serde_json::json!({ + "pubkey": pubkey, + "accepted": false, + "error": format!("failed to parse member-add response: {error}"), + "response": response, + })); + } + }, + Err(error) => { + failure_count += 1; + responses.push(serde_json::json!({ + "pubkey": pubkey, + "accepted": false, + "error": error.to_string(), + })); + } + } + } + println!("{}", serde_json::to_string(&responses).unwrap_or_default()); + if failure_count == 0 { + Ok(()) + } else { + Err(CliError::Other(format!( + "failed to add {failure_count} group member(s) to the channel" + ))) + } } pub async fn cmd_remove_channel_member( @@ -1158,6 +1272,9 @@ pub async fn dispatch( pubkey, role, } => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await, + ChannelsCmd::AddGroup { channel, group } => { + cmd_add_group_to_channel(client, &channel, &group).await + } ChannelsCmd::RemoveMember { channel, pubkey } => { cmd_remove_channel_member(client, &channel, &pubkey).await } diff --git a/crates/buzz-cli/src/commands/groups.rs b/crates/buzz-cli/src/commands/groups.rs new file mode 100644 index 0000000000..18517c9c29 --- /dev/null +++ b/crates/buzz-cli/src/commands/groups.rs @@ -0,0 +1,435 @@ +use buzz_core::kind::KIND_GROUP_STATE; +use nostr::Event; +use serde::Serialize; +use uuid::Uuid; + +use crate::client::{create_response_with_id, normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::parse_uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct GroupSnapshot { + pub group_id: String, + pub handle: String, + pub name: String, + pub description: Option, + pub creator: String, + pub members: Vec, + pub default_channels: Vec, + pub created_at: u64, +} + +fn tag_values(event: &serde_json::Value, key: &str) -> Vec { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|tag| { + let values = tag.as_array()?; + if values.first()?.as_str()? != key { + return None; + } + values.get(1)?.as_str().map(str::to_string) + }) + .collect() +} + +fn has_tag(event: &serde_json::Value, key: &str) -> bool { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .is_some_and(|tags| { + tags.iter().any(|tag| { + tag.as_array() + .and_then(|values| values.first()) + .and_then(serde_json::Value::as_str) + == Some(key) + }) + }) +} + +pub(crate) fn parse_group_snapshot(event: &serde_json::Value) -> Option { + if event.get("kind").and_then(serde_json::Value::as_u64) != Some(KIND_GROUP_STATE as u64) + || has_tag(event, "deleted") + { + return None; + } + + let mut members = tag_values(event, "p"); + members.sort(); + members.dedup(); + let mut default_channels = tag_values(event, "channel"); + default_channels.sort(); + default_channels.dedup(); + let description = tag_values(event, "description") + .into_iter() + .next() + .filter(|value| !value.is_empty()); + + Some(GroupSnapshot { + group_id: tag_values(event, "d").into_iter().next()?, + handle: tag_values(event, "handle").into_iter().next()?, + name: tag_values(event, "name").into_iter().next()?, + description, + creator: tag_values(event, "creator").into_iter().next()?, + members, + default_channels, + created_at: event + .get("created_at") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + }) +} + +pub(crate) async fn fetch_active_groups( + client: &BuzzClient, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_GROUP_STATE], + }); + let events = client.query_all(filter).await?; + let mut groups: Vec = events.iter().filter_map(parse_group_snapshot).collect(); + groups.sort_by(|left, right| { + left.handle + .cmp(&right.handle) + .then_with(|| left.group_id.cmp(&right.group_id)) + }); + Ok(groups) +} + +pub(crate) async fn resolve_group( + client: &BuzzClient, + reference: &str, +) -> Result { + let group = if let Ok(group_id) = Uuid::parse_str(reference) { + let group_id = group_id.to_string(); + let filter = serde_json::json!({ + "kinds": [KIND_GROUP_STATE], + "#d": [group_id], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("failed to parse group query: {error}")))?; + events.first().and_then(parse_group_snapshot) + } else { + fetch_active_groups(client) + .await? + .into_iter() + .find(|group| group.handle == reference) + }; + + group.ok_or_else(|| CliError::NotFound(format!("user group '{reference}' not found"))) +} + +fn format_groups(groups: &[GroupSnapshot], format: &crate::OutputFormat) -> String { + match format { + crate::OutputFormat::Json => serde_json::to_string(groups).unwrap_or_default(), + crate::OutputFormat::Compact => { + let compact: Vec = groups + .iter() + .map(|group| { + serde_json::json!({ + "group_id": group.group_id, + "handle": group.handle, + "name": group.name, + "member_count": group.members.len(), + }) + }) + .collect(); + serde_json::to_string(&compact).unwrap_or_default() + } + } +} + +fn validate_write_response(raw: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict(message.to_string())); + } + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + Ok(normalize_write_response(raw)) +} + +fn group_id_from_event(event: &Event) -> Result { + event + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("g")) + .then(|| values.get(1).cloned()) + .flatten() + }) + .ok_or_else(|| CliError::Other("group create event is missing its g tag".into())) +} + +fn parse_channel_ids(values: &[String]) -> Result, CliError> { + values.iter().map(|value| parse_uuid(value)).collect() +} + +fn map_group_submit_error(error: CliError) -> CliError { + match error { + CliError::Relay { status: 400, body } if body.starts_with("restricted:") => { + CliError::Auth(body) + } + other => other, + } +} + +async fn submit_group_event(client: &BuzzClient, event: Event) -> Result { + let raw = client + .submit_event(event) + .await + .map_err(map_group_submit_error)?; + validate_write_response(&raw) +} + +async fn submit_group_builder( + client: &BuzzClient, + builder: nostr::EventBuilder, +) -> Result { + let event = client.sign_event(builder)?; + submit_group_event(client, event).await +} + +async fn cmd_create( + client: &BuzzClient, + handle: &str, + name: &str, + description: Option<&str>, + members: &[String], + default_channels: &[String], +) -> Result<(), CliError> { + let channel_ids = parse_channel_ids(default_channels)?; + let member_refs: Vec<&str> = members.iter().map(String::as_str).collect(); + let builder = + buzz_sdk::build_group_create(handle, name, description, &member_refs, &channel_ids) + .map_err(|error| CliError::Usage(format!("invalid group create: {error}")))?; + let event = client.sign_event(builder)?; + let group_id = group_id_from_event(&event)?; + let normalized = submit_group_event(client, event).await?; + println!( + "{}", + create_response_with_id(&normalized, "group_id", &group_id) + ); + Ok(()) +} + +async fn cmd_edit( + client: &BuzzClient, + reference: &str, + handle: Option<&str>, + name: Option<&str>, + description: Option<&str>, + default_channels: Option<&[String]>, +) -> Result<(), CliError> { + let group = resolve_group(client, reference).await?; + let group_id = parse_uuid(&group.group_id)?; + let channel_ids = default_channels.map(parse_channel_ids).transpose()?; + let builder = + buzz_sdk::build_group_edit(group_id, handle, name, description, channel_ids.as_deref()) + .map_err(|error| CliError::Usage(format!("invalid group edit: {error}")))?; + println!("{}", submit_group_builder(client, builder).await?); + Ok(()) +} + +async fn cmd_delete(client: &BuzzClient, reference: &str) -> Result<(), CliError> { + let group = resolve_group(client, reference).await?; + let builder = buzz_sdk::build_group_delete(parse_uuid(&group.group_id)?) + .map_err(|error| CliError::Usage(format!("invalid group delete: {error}")))?; + println!("{}", submit_group_builder(client, builder).await?); + Ok(()) +} + +async fn cmd_members( + client: &BuzzClient, + reference: &str, + members: &[String], + add: bool, +) -> Result<(), CliError> { + let group = resolve_group(client, reference).await?; + let group_id = parse_uuid(&group.group_id)?; + let member_refs: Vec<&str> = members.iter().map(String::as_str).collect(); + let builder = if add { + buzz_sdk::build_group_add_members(group_id, &member_refs) + .map_err(|error| CliError::Usage(format!("invalid group members: {error}")))? + } else { + buzz_sdk::build_group_remove_members(group_id, &member_refs) + .map_err(|error| CliError::Usage(format!("invalid group members: {error}")))? + }; + println!("{}", submit_group_builder(client, builder).await?); + Ok(()) +} + +pub async fn dispatch( + cmd: crate::GroupsCmd, + client: &BuzzClient, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + use crate::GroupsCmd; + + match cmd { + GroupsCmd::Create { + handle, + name, + description, + members, + default_channels, + } => { + cmd_create( + client, + &handle, + &name, + description.as_deref(), + &members, + &default_channels, + ) + .await + } + GroupsCmd::Edit { + group, + handle, + name, + description, + default_channels, + clear_default_channels, + } => { + let default_channels = if clear_default_channels { + Some(Vec::new()) + } else if default_channels.is_empty() { + None + } else { + Some(default_channels) + }; + cmd_edit( + client, + &group, + handle.as_deref(), + name.as_deref(), + description.as_deref(), + default_channels.as_deref(), + ) + .await + } + GroupsCmd::Delete { group } => cmd_delete(client, &group).await, + GroupsCmd::List => { + let groups = fetch_active_groups(client).await?; + println!("{}", format_groups(&groups, format)); + Ok(()) + } + GroupsCmd::Get { group } => { + let group = resolve_group(client, &group).await?; + println!("{}", format_groups(&[group], format)); + Ok(()) + } + GroupsCmd::AddMembers { group, members } => { + cmd_members(client, &group, &members, true).await + } + GroupsCmd::RemoveMembers { group, members } => { + cmd_members(client, &group, &members, false).await + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + format_groups, map_group_submit_error, parse_group_snapshot, validate_write_response, + GroupSnapshot, + }; + use crate::error::CliError; + use crate::OutputFormat; + use serde_json::json; + + #[test] + fn parses_active_snapshot_and_normalizes_lists() { + let event = json!({ + "kind": 39100, + "created_at": 42, + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["handle", "ios-team"], + ["name", "iOS Team"], + ["description", "Mobile"], + ["creator", "creator"], + ["p", "bbbb"], + ["p", "aaaa"], + ["p", "aaaa"], + ["channel", "22222222-2222-2222-2222-222222222222"] + ] + }); + let group = parse_group_snapshot(&event).expect("active group"); + assert_eq!(group.handle, "ios-team"); + assert_eq!(group.members, ["aaaa", "bbbb"]); + assert_eq!(group.description.as_deref(), Some("Mobile")); + } + + #[test] + fn skips_tombstones() { + let event = json!({ + "kind": 39100, + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["deleted"] + ] + }); + assert!(parse_group_snapshot(&event).is_none()); + } + + #[test] + fn compact_output_keeps_scriptable_identity_and_count() { + let group = GroupSnapshot { + group_id: "id".into(), + handle: "ios-team".into(), + name: "iOS Team".into(), + description: None, + creator: "creator".into(), + members: vec!["a".into(), "b".into()], + default_channels: vec![], + created_at: 1, + }; + let output = format_groups(&[group], &OutputFormat::Compact); + assert_eq!( + serde_json::from_str::(&output).expect("JSON"), + json!([{ + "group_id": "id", + "handle": "ios-team", + "name": "iOS Team", + "member_count": 2 + }]) + ); + } + + #[test] + fn duplicate_handle_response_is_a_conflict() { + let error = validate_write_response( + r#"{"event_id":"abc","accepted":false,"message":"duplicate: user group handle already exists: ios-team"}"#, + ) + .expect_err("duplicate handle must fail"); + assert!(matches!(error, CliError::Conflict(_))); + assert_eq!(crate::error::exit_code(&error), 5); + } + + #[test] + fn restricted_group_write_is_an_auth_error() { + let error = map_group_submit_error(CliError::Relay { + status: 400, + body: "restricted: only the group creator may modify this group".into(), + }); + assert!(matches!(error, CliError::Auth(_))); + assert_eq!(crate::error::exit_code(&error), 3); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..238141c803 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,5 +1,5 @@ use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; -use nostr::PublicKey; +use nostr::{PublicKey, Tag}; use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; @@ -119,83 +119,148 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result Vec { - if !content.contains('@') { - return vec![]; - } - - // 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag). - let members_filter = serde_json::json!({ - "kinds": [39002], - "#d": [channel_id], - "limit": 1, - }); - let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await { - Some(pks) if !pks.is_empty() => pks, - _ => return vec![], - }; +#[derive(Debug, Default, PartialEq, Eq)] +struct MentionExpansion { + pubkeys: Vec, + groups: Vec, +} - // 2. Profiles for those members (kind 0). - let profiles_filter = serde_json::json!({ - "kinds": [0], - "authors": member_pubkeys, - "limit": member_pubkeys.len(), - }); - let profile_events = match fetch_events(client, &profiles_filter).await { - Some(v) => v, - None => return vec![], - }; +#[derive(Debug, PartialEq, Eq)] +struct GroupMention { + group_id: String, + handle: String, +} - // 3. Single parse: extract (pubkey, display_name) pairs from profile JSON. +fn resolve_mentions_from_data( + content: &str, + member_pubkeys: &[String], + profile_events: &[serde_json::Value], + groups: &[crate::commands::groups::GroupSnapshot], +) -> MentionExpansion { + let channel_members: std::collections::HashSet<&str> = + member_pubkeys.iter().map(String::as_str).collect(); let mut name_to_pubkeys: std::collections::HashMap> = std::collections::HashMap::new(); - let mut display_names: Vec = Vec::new(); - for e in &profile_events { - let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { + let mut known_names: Vec = Vec::new(); + + for event in profile_events { + let Some(pubkey) = event.get("pubkey").and_then(serde_json::Value::as_str) else { continue; }; - let Some(content_json) = e.get("content").and_then(|v| v.as_str()) else { + let Some(content_json) = event.get("content").and_then(serde_json::Value::as_str) else { continue; }; - let Ok(v) = serde_json::from_str::(content_json) else { + let Ok(profile) = serde_json::from_str::(content_json) else { continue; }; - let Some(name) = v + let Some(name) = profile .get("display_name") - .or_else(|| v.get("name")) - .and_then(|n| n.as_str()) - .filter(|n| !n.is_empty()) + .or_else(|| profile.get("name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.is_empty()) else { continue; }; - let lower = name.to_ascii_lowercase(); name_to_pubkeys - .entry(lower) + .entry(name.to_ascii_lowercase()) .or_default() .push(pubkey.to_string()); - display_names.push(name.to_string()); + known_names.push(name.to_string()); } + known_names.extend(groups.iter().map(|group| group.handle.clone())); - // 4. Two-pass extraction: known multi-word names first, single-word fallback. - let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect(); + let known_refs: Vec<&str> = known_names.iter().map(String::as_str).collect(); let names = extract_at_mentions_with_known(content, &known_refs); + let mut pubkeys = Vec::new(); + let mut seen_pubkeys = std::collections::HashSet::new(); + let mut mentioned_groups = Vec::new(); + let mut seen_groups = std::collections::HashSet::new(); + + for name in names { + if let Some(matches) = name_to_pubkeys.get(&name) { + for pubkey in matches { + if seen_pubkeys.insert(pubkey.clone()) { + pubkeys.push(pubkey.clone()); + } + } + } + for group in groups.iter().filter(|group| group.handle == name) { + if seen_groups.insert(group.group_id.clone()) { + mentioned_groups.push(GroupMention { + group_id: group.group_id.clone(), + handle: group.handle.clone(), + }); + } + for pubkey in &group.members { + if channel_members.contains(pubkey.as_str()) && seen_pubkeys.insert(pubkey.clone()) + { + pubkeys.push(pubkey.clone()); + } + } + } + } - // 5. Look up matched names → pubkeys via the map we already built. - names - .iter() - .flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten()) - .cloned() - .collect() + MentionExpansion { + pubkeys, + groups: mentioned_groups, + } +} + +/// Resolve individual and user-group mentions against current relay snapshots. +/// +/// Queries kind 39002 channel membership, kind 0 profiles, and active kind +/// 39100 user groups. All lookups are best-effort so an unavailable mention +/// index never blocks sending the author's text. +async fn resolve_content_mentions( + client: &BuzzClient, + channel_id: &str, + content: &str, +) -> Result { + if !content.contains('@') { + return Ok(MentionExpansion::default()); + } + + let groups = crate::commands::groups::fetch_active_groups(client) + .await + .unwrap_or_default(); + let members_filter = serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1, + }); + let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await { + Some(pubkeys) => pubkeys, + None => { + let group_handles: Vec<&str> = + groups.iter().map(|group| group.handle.as_str()).collect(); + if !extract_at_mentions_with_known(content, &group_handles).is_empty() { + return Err(CliError::Other( + "could not resolve channel membership for group mention".into(), + )); + } + Vec::new() + } + }; + + let profile_events = if member_pubkeys.is_empty() { + Vec::new() + } else { + let profiles_filter = serde_json::json!({ + "kinds": [0], + "authors": member_pubkeys, + "limit": member_pubkeys.len(), + }); + fetch_events(client, &profiles_filter) + .await + .unwrap_or_default() + }; + + Ok(resolve_mentions_from_data( + content, + &member_pubkeys, + &profile_events, + &groups, + )) } /// Fetch raw events for `filter` via the relay's `/query` endpoint. @@ -528,14 +593,20 @@ pub async fn cmd_send_message( // Resolve @name mentions in the author-written body only — not the media markdown we // append above, which is derived from upload metadata and can't carry `@names`. - let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await; + let mut mention_expansion = resolve_content_mentions(client, &p.channel_id, &p.content).await?; // NIP-27: also extract nostr:npub1… inline references (skipping code regions) let stripped = strip_code_regions(&p.content); let uri_pubkeys = extract_nostr_uris(&stripped); - merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP); + merge_mentions(&mut mention_expansion.pubkeys, &uri_pubkeys, MENTION_CAP); - let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); + // Ordinary mentions use the SDK's 50-tag cap. A user-group mention must + // still notify every in-channel member, so append any already-resolved + // overflow p-tags after the builder has validated the first 50. + let builder_mention_count = mention_expansion.pubkeys.len().min(MENTION_CAP); + let (builder_mentions, overflow_mentions) = + mention_expansion.pubkeys.split_at(builder_mention_count); + let mention_refs: Vec<&str> = builder_mentions.iter().map(String::as_str).collect(); let builder = match p.kind { Some(45001) => { @@ -570,6 +641,22 @@ pub async fn cmd_send_message( ))) } }; + let overflow_tags = overflow_mentions + .iter() + .map(|pubkey| { + Tag::parse(["p", pubkey.as_str()]) + .map_err(|error| CliError::Other(format!("group mention p tag failed: {error}"))) + }) + .collect::, _>>()?; + let group_tags = mention_expansion + .groups + .iter() + .map(|group| { + Tag::parse(["group", group.group_id.as_str(), group.handle.as_str()]) + .map_err(|error| CliError::Other(format!("group mention tag failed: {error}"))) + }) + .collect::, _>>()?; + let builder = builder.tags(overflow_tags).tags(group_tags); let event = client.sign_event(builder)?; @@ -876,7 +963,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + find_root_from_tags, match_profiles_by_name, parse_member_pubkeys, + resolve_mentions_from_data, GroupMention, MentionExpansion, + }; + use crate::commands::groups::GroupSnapshot; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -892,6 +983,74 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; + fn group(handle: &str, members: &[&str]) -> GroupSnapshot { + GroupSnapshot { + group_id: "11111111-1111-1111-1111-111111111111".into(), + handle: handle.into(), + name: "iOS Team".into(), + description: None, + creator: PK_VALID_A.into(), + members: members.iter().map(|member| (*member).to_string()).collect(), + default_channels: vec![], + created_at: 1, + } + } + + #[test] + fn group_mentions_expand_to_channel_member_intersection_and_marker() { + let profiles = vec![json!({ + "pubkey": PK_VALID_A, + "content": r#"{"display_name":"Alice"}"# + })]; + let groups = vec![group("ios-team", &[PK_VALID_B, PK_VALID_C])]; + let expansion = resolve_mentions_from_data( + "cc @Alice and @ios-team", + &[PK_VALID_A.into(), PK_VALID_B.into()], + &profiles, + &groups, + ); + + assert_eq!( + expansion, + MentionExpansion { + pubkeys: vec![PK_VALID_A.into(), PK_VALID_B.into()], + groups: vec![GroupMention { + group_id: "11111111-1111-1111-1111-111111111111".into(), + handle: "ios-team".into(), + }], + } + ); + } + + #[test] + fn unknown_and_tombstoned_group_handles_remain_plain_text() { + let expansion = + resolve_mentions_from_data("hello @former-team", &[PK_VALID_A.into()], &[], &[]); + assert_eq!(expansion, MentionExpansion::default()); + } + + #[test] + fn repeated_group_mentions_deduplicate_members_and_marker() { + let groups = vec![group("ios-team", &[PK_VALID_A, PK_VALID_A])]; + let expansion = resolve_mentions_from_data( + "@IOS-team then @ios-team", + &[PK_VALID_A.into()], + &[], + &groups, + ); + assert_eq!(expansion.pubkeys, [PK_VALID_A]); + assert_eq!(expansion.groups.len(), 1); + } + + #[test] + fn group_expansion_keeps_every_in_channel_member_past_individual_cap() { + let members: Vec = (1..=60).map(|index| format!("{index:064x}")).collect(); + let member_refs: Vec<&str> = members.iter().map(String::as_str).collect(); + let groups = vec![group("ios-team", &member_refs)]; + let expansion = resolve_mentions_from_data("@ios-team", &members, &[], &groups); + assert_eq!(expansion.pubkeys, members); + } + #[test] fn root_marker_wins_over_reply_marker() { let tags = json!([ diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..c869c26d71 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod groups; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..4900b1a502 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -182,6 +182,9 @@ enum Cmd { /// Create, configure, and manage channels #[command(subcommand)] Channels(ChannelsCmd), + /// Create and manage community user groups + #[command(subcommand)] + Groups(GroupsCmd), /// Get and set channel canvas documents #[command(subcommand)] Canvas(CanvasCmd), @@ -656,6 +659,16 @@ pub enum ChannelsCmd { #[arg(long)] role: Option, }, + /// Add every current member of a user group to a channel + #[command(name = "add-group")] + AddGroup { + /// Channel UUID + #[arg(long)] + channel: String, + /// User-group UUID or handle + #[arg(long)] + group: String, + }, /// Remove a member from a channel #[command(name = "remove-member")] RemoveMember { @@ -675,6 +688,78 @@ pub enum ChannelsCmd { }, } +#[derive(Subcommand)] +pub enum GroupsCmd { + /// Create a user group + Create { + /// Mention handle (without @) + #[arg(long)] + handle: String, + /// Display name + #[arg(long)] + name: String, + /// Optional description + #[arg(long)] + description: Option, + /// Initial member pubkey; repeat for multiple members + #[arg(long = "member")] + members: Vec, + /// Default public channel UUID; repeat for multiple channels + #[arg(long = "default-channel")] + default_channels: Vec, + }, + /// Edit group metadata or default channels + Edit { + /// User-group UUID or handle + group: String, + /// New mention handle + #[arg(long)] + handle: Option, + /// New display name + #[arg(long)] + name: Option, + /// New description; pass an empty string to clear + #[arg(long)] + description: Option, + /// Replacement default public channel UUID; repeat for multiple channels + #[arg(long = "default-channel")] + default_channels: Vec, + /// Clear all default channels + #[arg(long, conflicts_with = "default_channels")] + clear_default_channels: bool, + }, + /// Delete a user group + Delete { + /// User-group UUID or handle + group: String, + }, + /// List active user groups + List, + /// Get an active user group by UUID or handle + Get { + /// User-group UUID or handle + group: String, + }, + /// Add one or more group members + #[command(name = "add-members")] + AddMembers { + /// User-group UUID or handle + group: String, + /// Member pubkey; repeat for multiple members + #[arg(long = "member", visible_alias = "pubkey", required = true)] + members: Vec, + }, + /// Remove one or more group members + #[command(name = "remove-members")] + RemoveMembers { + /// User-group UUID or handle + group: String, + /// Member pubkey; repeat for multiple members + #[arg(long = "member", visible_alias = "pubkey", required = true)] + members: Vec, + }, +} + #[derive(Subcommand)] pub enum CanvasCmd { /// Get the canvas document for a channel @@ -1771,6 +1856,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, + Cmd::Groups(sub) => commands::groups::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, @@ -1803,6 +1889,69 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn parses_group_create_repeated_values() { + let cli = Cli::try_parse_from([ + "buzz", + "groups", + "create", + "--handle", + "ios-team", + "--name", + "iOS Team", + "--member", + "aaaa", + "--member", + "bbbb", + "--default-channel", + "11111111-1111-1111-1111-111111111111", + ]) + .expect("group create parses"); + let Cmd::Groups(GroupsCmd::Create { + handle, + members, + default_channels, + .. + }) = cli.command + else { + panic!("expected groups create"); + }; + assert_eq!(handle, "ios-team"); + assert_eq!(members, ["aaaa", "bbbb"]); + assert_eq!(default_channels, ["11111111-1111-1111-1111-111111111111"]); + } + + #[test] + fn parses_channel_add_group_flags() { + let cli = Cli::try_parse_from([ + "buzz", + "channels", + "add-group", + "--channel", + "11111111-1111-1111-1111-111111111111", + "--group", + "ios-team", + ]) + .expect("channel add-group parses"); + let Cmd::Channels(ChannelsCmd::AddGroup { channel, group }) = cli.command else { + panic!("expected channels add-group"); + }; + assert_eq!(channel, "11111111-1111-1111-1111-111111111111"); + assert_eq!(group, "ios-team"); + } + + #[test] + fn rejects_positional_channel_add_group_arguments() { + assert!(Cli::try_parse_from([ + "buzz", + "channels", + "add-group", + "11111111-1111-1111-1111-111111111111", + "ios-team", + ]) + .is_err()); + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ @@ -1812,6 +1961,7 @@ mod tests { "dms", "emoji", "feed", + "groups", "issues", "media", "mem", @@ -1894,6 +2044,7 @@ mod tests { assert_eq!( names(&cmd, "channels"), vec![ + "add-group", "add-member", "archive", "create", @@ -1912,6 +2063,18 @@ mod tests { "update" ] ); + assert_eq!( + names(&cmd, "groups"), + vec![ + "add-members", + "create", + "delete", + "edit", + "get", + "list", + "remove-members" + ] + ); assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( @@ -1997,10 +2160,11 @@ mod tests { let expected: Vec<(&str, usize)> = vec![ ("agents", 5), ("canvas", 2), - ("channels", 16), + ("channels", 17), ("dms", 4), ("emoji", 5), ("feed", 1), + ("groups", 7), ("issues", 4), ("media", 1), ("messages", 8), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..0e284eda67 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -301,6 +301,8 @@ pub const KIND_THREAD_SUMMARY: u32 = 39005; /// content = `{has_more, next_cursor}`. The only authority on exhaustion — /// clients must not infer `has_more` from row counts. pub const KIND_WINDOW_BOUNDS: u32 = 39006; +/// Relay-published user-group state snapshot (parameterized replaceable, `d` = group id). +pub const KIND_GROUP_STATE: u32 = 39100; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; @@ -446,6 +448,16 @@ pub const KIND_WORKFLOW_APPROVAL_GRANTED: u32 = 46011; pub const KIND_WORKFLOW_APPROVAL_DENIED: u32 = 46012; // User groups (47000–47999) +/// Create a user group. +pub const KIND_GROUP_CREATE: u32 = 47000; +/// Edit a user group's metadata or default channels. +pub const KIND_GROUP_EDIT: u32 = 47001; +/// Delete a user group. +pub const KIND_GROUP_DELETE: u32 = 47002; +/// Add one or more members to a user group. +pub const KIND_GROUP_ADD_MEMBER: u32 = 47003; +/// Remove one or more members from a user group. +pub const KIND_GROUP_REMOVE_MEMBER: u32 = 47004; // System / admin custom range (48000–48999) /// An audit log entry was recorded. @@ -545,6 +557,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_GROUP_ROLES, KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS, + KIND_GROUP_STATE, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, @@ -598,6 +611,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_WORKFLOW_APPROVAL_GRANTED, KIND_WORKFLOW_APPROVAL_DENIED, + KIND_GROUP_CREATE, + KIND_GROUP_EDIT, + KIND_GROUP_DELETE, + KIND_GROUP_ADD_MEMBER, + KIND_GROUP_REMOVE_MEMBER, KIND_AUDIT_ENTRY, KIND_HUDDLE_STARTED, KIND_HUDDLE_PARTICIPANT_JOINED, @@ -688,6 +706,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS + | KIND_GROUP_STATE ) } @@ -713,6 +732,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 303 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_GROUP_STATE)); // 39100 ∈ 30000–39999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( @@ -768,10 +788,34 @@ mod tests { assert!(is_parameterized_replaceable(30000)); assert!(is_parameterized_replaceable(30023)); // NIP-23 long-form assert!(is_parameterized_replaceable(39000)); // NIP-29 group metadata + assert!(is_parameterized_replaceable(KIND_GROUP_STATE)); assert!(is_parameterized_replaceable(39999)); assert!(!is_parameterized_replaceable(40000)); } + #[test] + fn user_group_kinds_are_registered() { + for kind in [ + KIND_GROUP_CREATE, + KIND_GROUP_EDIT, + KIND_GROUP_DELETE, + KIND_GROUP_ADD_MEMBER, + KIND_GROUP_REMOVE_MEMBER, + KIND_GROUP_STATE, + ] { + assert!( + ALL_KINDS.contains(&kind), + "unregistered user-group kind: {kind}" + ); + } + } + + #[test] + fn user_group_state_is_relay_only() { + assert!(is_relay_only_kind(KIND_GROUP_STATE)); + assert!(!is_relay_only_kind(KIND_GROUP_CREATE)); + } + #[test] fn replaceable_and_parameterized_are_disjoint() { for kind in 0..=65535u32 { diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dd57b46937..c28a031a9a 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -34,6 +34,8 @@ pub mod presence; pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; +/// User-group validation helpers. +pub mod user_group; /// Schnorr signature and event ID verification. pub mod verification; diff --git a/crates/buzz-core/src/user_group.rs b/crates/buzz-core/src/user_group.rs new file mode 100644 index 0000000000..c39aa4d773 --- /dev/null +++ b/crates/buzz-core/src/user_group.rs @@ -0,0 +1,58 @@ +//! User-group validation helpers. + +/// Maximum number of members in one user group or membership command. +pub const MAX_USER_GROUP_MEMBERS: usize = 256; + +/// Maximum number of default channels attached to one user group. +pub const MAX_USER_GROUP_DEFAULT_CHANNELS: usize = 32; + +/// Returns whether `handle` matches `^[a-z0-9][a-z0-9_-]{1,31}$`. +/// +/// Handles are ASCII-only, contain between 2 and 32 characters, and must +/// start with a lowercase letter or digit. +pub fn is_valid_group_handle(handle: &str) -> bool { + let bytes = handle.as_bytes(); + if !(2..=32).contains(&bytes.len()) { + return false; + } + + (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'_' | b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid_group_handles() { + for handle in ["ab", "ios-team", "team_42", "0platform", &"a".repeat(32)] { + assert!( + is_valid_group_handle(handle), + "expected valid handle: {handle}" + ); + } + } + + #[test] + fn rejects_invalid_group_handles() { + for handle in [ + "", + "a", + "-team", + "_team", + "Team", + "team.name", + "team name", + "tëam", + &"a".repeat(33), + ] { + assert!( + !is_valid_group_handle(handle), + "expected invalid handle: {handle}" + ); + } + } +} diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..7398c8516b 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -29,6 +29,22 @@ pub enum DbError { #[error("member not found in channel {0}")] MemberNotFound(uuid::Uuid), + /// The requested active user group does not exist. + #[error("user group not found: {0}")] + UserGroupNotFound(uuid::Uuid), + + /// The requested user-group handle is already active in this community. + #[error("user group handle already exists: {0}")] + UserGroupHandleConflict(String), + + /// The requested user-group UUID was already used, including by a deleted group. + #[error("user group id already used: {0}")] + UserGroupIdConflict(uuid::Uuid), + + /// A user-group membership write would exceed the supported size. + #[error("user group supports at most {0} members")] + UserGroupMemberLimit(usize), + /// A generic not-found error. #[error("not found: {0}")] NotFound(String), diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 5adcb05bdc..a5875e40c8 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1001,7 +1001,7 @@ pub struct ThreadMetadataParams<'a> { pub broadcast: bool, } -async fn insert_event_with_thread_metadata_tx( +pub(crate) async fn insert_event_with_thread_metadata_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, event: &Event, @@ -1431,7 +1431,7 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..2ff79f69fc 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -49,6 +49,8 @@ pub mod thread; pub mod usage; /// User profile persistence. pub mod user; +/// Community-scoped user-group persistence. +pub mod user_group; /// Workflow, run, and approval persistence. pub mod workflow; @@ -1433,6 +1435,137 @@ impl Db { Ok(outcome) } + /// Creates a user group with its initial members and default channels. + /// + /// Returns [`DbError::UserGroupHandleConflict`] when an active group in + /// the same community already owns `handle`. + #[allow(clippy::too_many_arguments)] + pub async fn create_user_group( + &self, + community: CommunityId, + group_id: Uuid, + handle: &str, + name: &str, + description: Option<&str>, + created_by: &str, + members: &[String], + default_channels: &[Uuid], + ) -> Result { + user_group::create_group( + &self.pool, + community, + group_id, + handle, + name, + description, + created_by, + members, + default_channels, + ) + .await + } + + /// Returns an active user group by its community-scoped UUID. + pub async fn get_user_group_by_id( + &self, + community: CommunityId, + group_id: Uuid, + ) -> Result { + user_group::get_group_by_id(&self.pool, community, group_id).await + } + + /// Returns an active user group by mention handle, if present. + pub async fn get_user_group_by_handle( + &self, + community: CommunityId, + handle: &str, + ) -> Result> { + user_group::get_group_by_handle(&self.pool, community, handle).await + } + + /// Lists active user groups in a community. + pub async fn list_user_groups( + &self, + community: CommunityId, + ) -> Result> { + user_group::list_groups(&self.pool, community).await + } + + /// Updates user-group metadata and optionally replaces default channels. + pub async fn update_user_group( + &self, + community: CommunityId, + group_id: Uuid, + updates: user_group::UserGroupUpdate, + ) -> Result { + user_group::update_group(&self.pool, community, group_id, updates).await + } + + /// Soft-deletes a user group. + pub async fn soft_delete_user_group( + &self, + community: CommunityId, + group_id: Uuid, + ) -> Result> { + user_group::soft_delete_group(&self.pool, community, group_id).await + } + + /// Adds members to an active user group. + pub async fn add_user_group_members( + &self, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], + added_by: &str, + ) -> Result { + user_group::add_members(&self.pool, community, group_id, pubkeys, added_by).await + } + + /// Removes members from an active user group. + pub async fn remove_user_group_members( + &self, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], + ) -> Result { + user_group::remove_members(&self.pool, community, group_id, pubkeys).await + } + + /// Lists members of an active user group. + pub async fn list_user_group_members( + &self, + community: CommunityId, + group_id: Uuid, + ) -> Result> { + user_group::list_members(&self.pool, community, group_id).await + } + + /// Lists default channels of an active user group. + pub async fn list_user_group_default_channels( + &self, + community: CommunityId, + group_id: Uuid, + ) -> Result> { + user_group::list_default_channels(&self.pool, community, group_id).await + } + + /// Atomically stores a user-group command event and applies its SQL mutation. + pub async fn insert_user_group_command_event( + &self, + community: CommunityId, + event: &nostr::Event, + mutation: user_group::UserGroupMutation, + ) -> Result { + let result = + user_group::insert_command_event(&self.pool, community, event, mutation).await?; + if result.was_inserted { + if let Err(error) = insert_mentions(&self.pool, community, event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {error}"); + } + } + Ok(result) + } + /// Creates a new channel, bootstraps the creator as owner, and returns the record. #[allow(clippy::too_many_arguments)] pub async fn create_channel( @@ -1863,6 +1996,15 @@ impl Db { user::get_agent_channel_policy(&self.pool, community_id, pubkey).await } + /// Bulk-load stored community membership and channel-add policy facts. + pub async fn get_community_member_facts( + &self, + community_id: CommunityId, + pubkeys: &[Vec], + ) -> Result> { + user::get_community_member_facts(&self.pool, community_id, pubkeys).await + } + /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. pub async fn is_agent_owner( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..6c6fd5a849 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +879,16 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // User-group state is additive and community-scoped. The migration + // carries the metadata, membership, and default-channel tables. + assert_eq!(migrations[24].version, 25); + let user_groups = migrations[24].sql.as_str(); + assert!(user_groups.contains("CREATE TABLE user_groups")); + assert!(user_groups.contains("CREATE TABLE user_group_members")); + assert!(user_groups.contains("CREATE TABLE user_group_default_channels")); + assert!(user_groups.contains("idx_user_groups_active_handle")); + assert!(user_groups.contains("snapshot_version BIGINT")); } #[test] @@ -1121,7 +1131,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(24)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); } #[tokio::test] diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 066fb5f5c0..e185a30788 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -33,6 +33,23 @@ pub struct UserSearchProfile { pub nip05_handle: Option, } +/// Stored facts used by relay-side community admission and channel consent checks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityMemberFacts { + /// Requested pubkey. + pub pubkey: Vec, + /// Whether the community has a user row for this pubkey. + pub user_exists: bool, + /// Whether the pubkey is a direct relay member. + pub relay_member: bool, + /// Stored NIP-OA owner, if this user is an agent. + pub agent_owner: Option>, + /// Whether the stored owner is a direct relay member. + pub owner_relay_member: bool, + /// Target's channel-add consent policy. + pub channel_add_policy: String, +} + /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. /// @@ -348,6 +365,47 @@ pub async fn get_agent_channel_policy( .transpose() } +/// Bulk-load community admission and channel-add policy facts for pubkeys. +pub async fn get_community_member_facts( + pool: &PgPool, + community_id: CommunityId, + pubkeys: &[Vec], +) -> Result> { + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + let rows = sqlx::query( + "SELECT requested.pubkey, users.pubkey IS NOT NULL AS user_exists, \ + direct.pubkey IS NOT NULL AS relay_member, users.agent_owner_pubkey, \ + owner.pubkey IS NOT NULL AS owner_relay_member, \ + COALESCE(users.channel_add_policy::text, 'anyone') AS channel_add_policy \ + FROM UNNEST($2::bytea[]) AS requested(pubkey) \ + LEFT JOIN users ON users.community_id = $1 AND users.pubkey = requested.pubkey \ + LEFT JOIN relay_members direct \ + ON direct.community_id = $1 AND direct.pubkey = encode(requested.pubkey, 'hex') \ + LEFT JOIN relay_members owner \ + ON owner.community_id = $1 \ + AND owner.pubkey = encode(users.agent_owner_pubkey, 'hex')", + ) + .bind(community_id.as_uuid()) + .bind(pubkeys) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(CommunityMemberFacts { + pubkey: row.try_get("pubkey")?, + user_exists: row.try_get("user_exists")?, + relay_member: row.try_get("relay_member")?, + agent_owner: row.try_get("agent_owner_pubkey")?, + owner_relay_member: row.try_get("owner_relay_member")?, + channel_add_policy: row.try_get("channel_add_policy")?, + }) + }) + .collect() +} + /// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`. /// Queries `agent_owner_pubkey` directly rather than going through /// `get_agent_channel_policy`, which would fetch unrelated fields. @@ -404,7 +462,7 @@ mod tests { use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_db() -> Db { let pool = PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/user_group.rs b/crates/buzz-db/src/user_group.rs new file mode 100644 index 0000000000..d64b38caa8 --- /dev/null +++ b/crates/buzz-db/src/user_group.rs @@ -0,0 +1,1256 @@ +//! Community-scoped user-group persistence. +//! +//! User groups are soft-deleted shared objects. Their handles are unique among +//! active groups within a community, and membership/default-channel writes are +//! transactionally fenced against concurrent group deletion. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; +use uuid::Uuid; + +use crate::error::{DbError, Result}; +use buzz_core::{CommunityId, StoredEvent}; + +const ACTIVE_HANDLE_INDEX: &str = "idx_user_groups_active_handle"; +const PRIMARY_KEY_CONSTRAINT: &str = "user_groups_pkey"; + +/// A user-group metadata row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserGroupRecord { + /// Stable group UUID within the community. + pub id: Uuid, + /// Unique active mention handle. + pub handle: String, + /// Human-readable display name. + pub name: String, + /// Optional group description. + pub description: Option, + /// Hex pubkey of the group creator. + pub created_by: String, + /// Monotonic Nostr timestamp used to order relay-signed snapshots. + pub snapshot_version: i64, + /// When the group was created. + pub created_at: DateTime, + /// When metadata, membership, or default channels were last updated. + pub updated_at: DateTime, + /// When the group was soft-deleted, if applicable. + pub deleted_at: Option>, +} + +/// A user-group membership row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserGroupMemberRecord { + /// Group containing the member. + pub group_id: Uuid, + /// 64-character hex member pubkey. + pub pubkey: String, + /// Hex pubkey of the actor who added the member. + pub added_by: String, + /// When the member was added. + pub added_at: DateTime, +} + +/// Result of adding members to a user group. +/// +/// The default channels are read while holding the same group-row lock used +/// for insertion. A relay can therefore auto-join only `added_pubkeys` to this +/// exact channel snapshot without making later default-list edits retroactive. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AddMembersResult { + /// Pubkeys newly inserted by this operation, in lexical order. + pub added_pubkeys: Vec, + /// Default channels observed atomically with the membership insert. + pub default_channels: Vec, + /// Snapshot version assigned to this mutation. + pub snapshot_version: i64, +} + +/// Result of removing members from a user group. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemoveMembersResult { + /// Number of memberships removed. + pub removed: u64, + /// Snapshot version assigned to this mutation. + pub snapshot_version: i64, +} + +/// Partial user-group update. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserGroupUpdate { + /// New mention handle, or `None` to leave it unchanged. + pub handle: Option, + /// New display name, or `None` to leave it unchanged. + pub name: Option, + /// Description change: outer `None` leaves it unchanged, `Some(None)` + /// clears it, and `Some(Some(value))` replaces it. + pub description: Option>, + /// Full replacement default-channel list, or `None` to leave it unchanged. + /// An empty vector clears the list. + pub default_channels: Option>, +} + +/// Parsed user-group mutation to commit atomically with its command event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserGroupMutation { + /// Create a group. + Create { + /// New group UUID. + group_id: Uuid, + /// Mention handle. + handle: String, + /// Display name. + name: String, + /// Optional description. + description: Option, + /// Initial member pubkeys. + members: Vec, + /// Initial default channels. + default_channels: Vec, + /// Creator pubkey. + created_by: String, + }, + /// Edit metadata or default channels. + Edit { + /// Group UUID. + group_id: Uuid, + /// Requested updates. + updates: UserGroupUpdate, + }, + /// Soft-delete a group. + Delete { + /// Group UUID. + group_id: Uuid, + }, + /// Add members. + AddMembers { + /// Group UUID. + group_id: Uuid, + /// Pubkeys to add. + members: Vec, + /// Actor pubkey. + added_by: String, + }, + /// Remove members. + RemoveMembers { + /// Group UUID. + group_id: Uuid, + /// Pubkeys to remove. + members: Vec, + }, +} + +/// Committed user-group mutation details used by relay post-commit effects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserGroupMutationResult { + /// Group created. + Created { + /// Group UUID. + group_id: Uuid, + /// Handle used for logging. + handle: String, + /// Snapshot version. + snapshot_version: i64, + }, + /// Group edited. + Edited { + /// Group UUID. + group_id: Uuid, + /// Snapshot version. + snapshot_version: i64, + }, + /// Group deleted. + Deleted { + /// Group UUID. + group_id: Uuid, + /// Tombstone snapshot version. + snapshot_version: i64, + }, + /// Members added. + MembersAdded { + /// Group UUID. + group_id: Uuid, + /// Membership result. + result: AddMembersResult, + }, + /// Members removed. + MembersRemoved { + /// Group UUID. + group_id: Uuid, + /// Membership result. + result: RemoveMembersResult, + }, +} + +/// Result of atomically inserting a command event and applying its mutation. +#[derive(Debug)] +pub struct UserGroupCommandEventResult { + /// Stored command event. + pub stored_event: StoredEvent, + /// Whether this command event was new. + pub was_inserted: bool, + /// Mutation result; absent for an identical event duplicate. + pub mutation: Option, +} + +/// Atomically stores a signed user-group command and applies its SQL mutation. +pub async fn insert_command_event( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + mutation: UserGroupMutation, +) -> Result { + let mut tx = pool.begin().await?; + let (stored_event, was_inserted) = + crate::event::insert_event_with_thread_metadata_tx(&mut tx, community, event, None, None) + .await?; + if !was_inserted { + tx.rollback().await?; + return Ok(UserGroupCommandEventResult { + stored_event, + was_inserted, + mutation: None, + }); + } + + let mutation = match mutation { + UserGroupMutation::Create { + group_id, + handle, + name, + description, + members, + default_channels, + created_by, + } => { + let record = create_group_tx( + &mut tx, + community, + group_id, + &handle, + &name, + description.as_deref(), + &created_by, + &members, + &default_channels, + ) + .await?; + UserGroupMutationResult::Created { + group_id, + handle, + snapshot_version: record.snapshot_version, + } + } + UserGroupMutation::Edit { group_id, updates } => { + let record = update_group_tx(&mut tx, community, group_id, &updates).await?; + UserGroupMutationResult::Edited { + group_id, + snapshot_version: record.snapshot_version, + } + } + UserGroupMutation::Delete { group_id } => { + let snapshot_version = soft_delete_group_tx(&mut tx, community, group_id) + .await? + .ok_or(DbError::UserGroupNotFound(group_id))?; + UserGroupMutationResult::Deleted { + group_id, + snapshot_version, + } + } + UserGroupMutation::AddMembers { + group_id, + members, + added_by, + } => { + let result = add_members_tx(&mut tx, community, group_id, &members, &added_by).await?; + UserGroupMutationResult::MembersAdded { group_id, result } + } + UserGroupMutation::RemoveMembers { group_id, members } => { + let result = remove_members_tx(&mut tx, community, group_id, &members).await?; + UserGroupMutationResult::MembersRemoved { group_id, result } + } + }; + + tx.commit().await?; + Ok(UserGroupCommandEventResult { + stored_event, + was_inserted, + mutation: Some(mutation), + }) +} + +/// Creates a user group, including its initial members and default channels. +/// +/// The insert is atomic across all three user-group tables. A handle already +/// used by an active group returns [`DbError::UserGroupHandleConflict`]. +#[allow(clippy::too_many_arguments)] +pub async fn create_group( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, + handle: &str, + name: &str, + description: Option<&str>, + created_by: &str, + members: &[String], + default_channels: &[Uuid], +) -> Result { + let mut tx = pool.begin().await?; + let record = create_group_tx( + &mut tx, + community, + group_id, + handle, + name, + description, + created_by, + members, + default_channels, + ) + .await?; + tx.commit().await?; + Ok(record) +} + +/// Returns an active user group by community-scoped UUID. +pub async fn get_group_by_id( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, +) -> Result { + let row = sqlx::query( + "SELECT id, handle, name, description, created_by, snapshot_version, created_at, updated_at, deleted_at \ + FROM user_groups \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_optional(pool) + .await? + .ok_or(DbError::UserGroupNotFound(group_id))?; + + row_to_group_record(row) +} + +/// Returns an active user group by its community-scoped mention handle. +pub async fn get_group_by_handle( + pool: &PgPool, + community: CommunityId, + handle: &str, +) -> Result> { + let row = sqlx::query( + "SELECT id, handle, name, description, created_by, snapshot_version, created_at, updated_at, deleted_at \ + FROM user_groups \ + WHERE community_id = $1 AND handle = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(handle) + .fetch_optional(pool) + .await?; + + row.map(row_to_group_record).transpose() +} + +/// Lists all active user groups in a community, ordered by handle. +pub async fn list_groups(pool: &PgPool, community: CommunityId) -> Result> { + let rows = sqlx::query( + "SELECT id, handle, name, description, created_by, snapshot_version, created_at, updated_at, deleted_at \ + FROM user_groups \ + WHERE community_id = $1 AND deleted_at IS NULL \ + ORDER BY handle ASC, id ASC", + ) + .bind(community.as_uuid()) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_group_record).collect() +} + +/// Updates group metadata and optionally replaces the entire default-channel list. +/// +/// At least one metadata field or `default_channels` replacement must be +/// supplied. Metadata and default-channel replacement commit atomically. +pub async fn update_group( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, + updates: UserGroupUpdate, +) -> Result { + let mut tx = pool.begin().await?; + let record = update_group_tx(&mut tx, community, group_id, &updates).await?; + tx.commit().await?; + Ok(record) +} + +/// Soft-deletes a user group. +/// +/// Returns the tombstone snapshot version, or `None` when the group was already +/// deleted or absent. +pub async fn soft_delete_group( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, +) -> Result> { + let mut tx = pool.begin().await?; + let version = soft_delete_group_tx(&mut tx, community, group_id).await?; + tx.commit().await?; + Ok(version) +} + +/// Adds members to an active user group. +/// +/// Existing memberships are left unchanged. The result identifies the newly +/// inserted pubkeys and the default channels observed while the group row was +/// locked, so callers can apply default-channel side effects without races. +pub async fn add_members( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], + added_by: &str, +) -> Result { + let mut tx = pool.begin().await?; + let result = add_members_tx(&mut tx, community, group_id, pubkeys, added_by).await?; + tx.commit().await?; + Ok(result) +} + +/// Removes members from an active user group. +/// +/// Missing memberships are ignored. Returns the number of removed rows. +pub async fn remove_members( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], +) -> Result { + let mut tx = pool.begin().await?; + let result = remove_members_tx(&mut tx, community, group_id, pubkeys).await?; + tx.commit().await?; + Ok(result) +} + +/// Lists members of an active user group, ordered by insertion time and pubkey. +pub async fn list_members( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, +) -> Result> { + let rows = sqlx::query( + "SELECT members.group_id, members.pubkey, members.added_by, members.added_at \ + FROM user_group_members members \ + JOIN user_groups groups \ + ON groups.community_id = members.community_id \ + AND groups.id = members.group_id \ + AND groups.deleted_at IS NULL \ + WHERE members.community_id = $1 AND members.group_id = $2 \ + ORDER BY members.added_at ASC, members.pubkey ASC", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + get_group_by_id(pool, community, group_id).await?; + } + rows.into_iter().map(row_to_member_record).collect() +} + +/// Lists the full default-channel list for an active user group. +pub async fn list_default_channels( + pool: &PgPool, + community: CommunityId, + group_id: Uuid, +) -> Result> { + let rows = sqlx::query( + "SELECT defaults.channel_id \ + FROM user_group_default_channels defaults \ + JOIN user_groups groups \ + ON groups.community_id = defaults.community_id \ + AND groups.id = defaults.group_id \ + AND groups.deleted_at IS NULL \ + WHERE defaults.community_id = $1 AND defaults.group_id = $2 \ + ORDER BY defaults.channel_id ASC", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + get_group_by_id(pool, community, group_id).await?; + } + rows.into_iter() + .map(|row| row.try_get("channel_id").map_err(DbError::from)) + .collect() +} + +#[allow(clippy::too_many_arguments)] +async fn create_group_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + handle: &str, + name: &str, + description: Option<&str>, + created_by: &str, + members: &[String], + default_channels: &[Uuid], +) -> Result { + let row = match sqlx::query( + "INSERT INTO user_groups \ + (community_id, id, handle, name, description, created_by) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING id, handle, name, description, created_by, snapshot_version, created_at, updated_at, deleted_at", + ) + .bind(community.as_uuid()) + .bind(group_id) + .bind(handle) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(&mut **tx) + .await + { + Ok(row) => row, + Err(error) => return Err(map_create_conflict(error, group_id, handle)), + }; + + let record = row_to_group_record(row)?; + let _ = insert_members_tx(tx, community, group_id, members, created_by).await?; + insert_default_channels_tx(tx, community, group_id, default_channels).await?; + Ok(record) +} + +async fn update_group_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + updates: &UserGroupUpdate, +) -> Result { + let description_changed = updates.description.is_some(); + let description = updates + .description + .as_ref() + .and_then(|value| value.as_deref()); + let conflict_handle = updates.handle.as_deref().unwrap_or_default(); + let row = match sqlx::query( + "UPDATE user_groups SET \ + handle = COALESCE($1, handle), \ + name = COALESCE($2, name), \ + description = CASE WHEN $3 THEN $4 ELSE description END, \ + snapshot_version = GREATEST(snapshot_version + 1, EXTRACT(EPOCH FROM clock_timestamp())::BIGINT), \ + updated_at = now() \ + WHERE community_id = $5 AND id = $6 AND deleted_at IS NULL \ + RETURNING id, handle, name, description, created_by, snapshot_version, created_at, updated_at, deleted_at", + ) + .bind(updates.handle.as_deref()) + .bind(updates.name.as_deref()) + .bind(description_changed) + .bind(description) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_optional(&mut **tx) + .await + { + Ok(Some(row)) => row, + Ok(None) => return Err(DbError::UserGroupNotFound(group_id)), + Err(error) => return Err(map_handle_conflict(error, conflict_handle)), + }; + + if let Some(default_channels) = updates.default_channels.as_deref() { + sqlx::query( + "DELETE FROM user_group_default_channels \ + WHERE community_id = $1 AND group_id = $2", + ) + .bind(community.as_uuid()) + .bind(group_id) + .execute(&mut **tx) + .await?; + insert_default_channels_tx(tx, community, group_id, default_channels).await?; + } + + row_to_group_record(row) +} + +async fn soft_delete_group_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, +) -> Result> { + sqlx::query_scalar( + "UPDATE user_groups \ + SET deleted_at = now(), updated_at = now(), \ + snapshot_version = GREATEST(snapshot_version + 1, EXTRACT(EPOCH FROM clock_timestamp())::BIGINT) \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + RETURNING snapshot_version", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_optional(&mut **tx) + .await + .map_err(DbError::from) +} + +async fn add_members_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], + added_by: &str, +) -> Result { + lock_active_group(tx, community, group_id).await?; + let mut added_pubkeys = insert_members_tx(tx, community, group_id, pubkeys, added_by).await?; + let member_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_group_members \ + WHERE community_id = $1 AND group_id = $2", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_one(&mut **tx) + .await?; + if member_count > buzz_core::user_group::MAX_USER_GROUP_MEMBERS as i64 { + return Err(DbError::UserGroupMemberLimit( + buzz_core::user_group::MAX_USER_GROUP_MEMBERS, + )); + } + if !added_pubkeys.is_empty() { + touch_group_tx(tx, community, group_id).await?; + } + let default_channels = list_default_channels_tx(tx, community, group_id).await?; + let snapshot_version = get_snapshot_version_tx(tx, community, group_id).await?; + added_pubkeys.sort_unstable(); + Ok(AddMembersResult { + added_pubkeys, + default_channels, + snapshot_version, + }) +} + +async fn remove_members_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], +) -> Result { + lock_active_group(tx, community, group_id).await?; + let result = sqlx::query( + "DELETE FROM user_group_members \ + WHERE community_id = $1 AND group_id = $2 AND pubkey = ANY($3)", + ) + .bind(community.as_uuid()) + .bind(group_id) + .bind(pubkeys) + .execute(&mut **tx) + .await?; + if result.rows_affected() > 0 { + touch_group_tx(tx, community, group_id).await?; + } + let snapshot_version = get_snapshot_version_tx(tx, community, group_id).await?; + Ok(RemoveMembersResult { + removed: result.rows_affected(), + snapshot_version, + }) +} + +fn row_to_group_record(row: sqlx::postgres::PgRow) -> Result { + Ok(UserGroupRecord { + id: row.try_get("id")?, + handle: row.try_get("handle")?, + name: row.try_get("name")?, + description: row.try_get("description")?, + created_by: row.try_get("created_by")?, + snapshot_version: row.try_get("snapshot_version")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + deleted_at: row.try_get("deleted_at")?, + }) +} + +fn row_to_member_record(row: sqlx::postgres::PgRow) -> Result { + Ok(UserGroupMemberRecord { + group_id: row.try_get("group_id")?, + pubkey: row.try_get("pubkey")?, + added_by: row.try_get("added_by")?, + added_at: row.try_get("added_at")?, + }) +} + +fn map_create_conflict(error: sqlx::Error, group_id: Uuid, handle: &str) -> DbError { + if let sqlx::Error::Database(database_error) = &error { + if database_error.code().as_deref() == Some("23505") { + return match database_error.constraint() { + Some(ACTIVE_HANDLE_INDEX) => DbError::UserGroupHandleConflict(handle.to_owned()), + Some(PRIMARY_KEY_CONSTRAINT) => DbError::UserGroupIdConflict(group_id), + _ => error.into(), + }; + } + } + error.into() +} + +fn map_handle_conflict(error: sqlx::Error, handle: &str) -> DbError { + if matches!( + &error, + sqlx::Error::Database(database_error) + if database_error.code().as_deref() == Some("23505") + && database_error.constraint() == Some(ACTIVE_HANDLE_INDEX) + ) { + DbError::UserGroupHandleConflict(handle.to_owned()) + } else { + error.into() + } +} + +async fn lock_active_group( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, +) -> Result<()> { + let exists = sqlx::query( + "SELECT 1 FROM user_groups \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_optional(&mut **tx) + .await?; + if exists.is_none() { + return Err(DbError::UserGroupNotFound(group_id)); + } + Ok(()) +} + +async fn insert_members_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + pubkeys: &[String], + added_by: &str, +) -> Result> { + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + if pubkeys.len() > buzz_core::user_group::MAX_USER_GROUP_MEMBERS { + return Err(DbError::UserGroupMemberLimit( + buzz_core::user_group::MAX_USER_GROUP_MEMBERS, + )); + } + + let mut query = QueryBuilder::::new( + "INSERT INTO user_group_members \ + (community_id, group_id, pubkey, added_by) ", + ); + query.push_values(pubkeys, |mut row, pubkey| { + row.push_bind(community.as_uuid()) + .push_bind(group_id) + .push_bind(pubkey) + .push_bind(added_by); + }); + query.push( + " ON CONFLICT (community_id, group_id, pubkey) DO NOTHING \ + RETURNING pubkey", + ); + let rows = query.build().fetch_all(&mut **tx).await?; + rows.into_iter() + .map(|row| row.try_get("pubkey").map_err(DbError::from)) + .collect() +} + +async fn insert_default_channels_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, + channel_ids: &[Uuid], +) -> Result { + if channel_ids.is_empty() { + return Ok(0); + } + if channel_ids.len() > buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS { + return Err(DbError::InvalidData(format!( + "user groups support at most {} default channels", + buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + ))); + } + + let mut query = QueryBuilder::::new( + "INSERT INTO user_group_default_channels \ + (community_id, group_id, channel_id) ", + ); + query.push_values(channel_ids, |mut row, channel_id| { + row.push_bind(community.as_uuid()) + .push_bind(group_id) + .push_bind(channel_id); + }); + query.push(" ON CONFLICT (community_id, group_id, channel_id) DO NOTHING"); + let result = query.build().execute(&mut **tx).await?; + Ok(result.rows_affected()) +} + +async fn touch_group_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, +) -> Result<()> { + sqlx::query( + "UPDATE user_groups SET updated_at = now(), \ + snapshot_version = GREATEST(snapshot_version + 1, EXTRACT(EPOCH FROM clock_timestamp())::BIGINT) \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(group_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn get_snapshot_version_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, +) -> Result { + sqlx::query_scalar( + "SELECT snapshot_version FROM user_groups \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_one(&mut **tx) + .await + .map_err(DbError::from) +} + +async fn list_default_channels_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + group_id: Uuid, +) -> Result> { + let rows = sqlx::query( + "SELECT channel_id FROM user_group_default_channels \ + WHERE community_id = $1 AND group_id = $2 \ + ORDER BY channel_id ASC", + ) + .bind(community.as_uuid()) + .bind(group_id) + .fetch_all(&mut **tx) + .await?; + rows.into_iter() + .map(|row| row.try_get("channel_id").map_err(DbError::from)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("user-group-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + async fn make_channel(pool: &PgPool, community: CommunityId) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels \ + (community_id, id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, $3, 'stream', 'open', $4)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(format!("group-default-{}", id.simple())) + .bind(vec![1_u8; 32]) + .execute(pool) + .await + .expect("insert test channel"); + id + } + + fn pubkey(marker: char) -> String { + marker.to_string().repeat(64) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn group_crud_members_defaults_conflict_and_soft_delete() { + let pool = setup_pool().await; + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let community = make_community(&pool).await; + let channel_a = make_channel(&pool, community).await; + let channel_b = make_channel(&pool, community).await; + let creator = pubkey('a'); + let initial = vec![pubkey('b')]; + let group_id = Uuid::new_v4(); + + let created = create_group( + &pool, + community, + group_id, + "ios-team", + "iOS Team", + Some("Mobile engineers"), + &creator, + &initial, + &[channel_a], + ) + .await + .expect("create group"); + assert_eq!(created.handle, "ios-team"); + assert_eq!( + get_group_by_handle(&pool, community, "ios-team") + .await + .expect("get by handle") + .expect("active group") + .id, + group_id + ); + + let conflict = create_group( + &pool, + community, + Uuid::new_v4(), + "ios-team", + "Other iOS Team", + None, + &creator, + &[], + &[], + ) + .await + .expect_err("duplicate active handle must conflict"); + assert!(matches!( + conflict, + DbError::UserGroupHandleConflict(ref handle) if handle == "ios-team" + )); + + let command_event = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_GROUP_CREATE as u16), + "", + ) + .tags([ + nostr::Tag::parse(["g", &Uuid::new_v4().to_string()]).expect("g tag"), + nostr::Tag::parse(["handle", "ios-team"]).expect("handle tag"), + nostr::Tag::parse(["name", "Conflicting Team"]).expect("name tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign command event"); + let command_id = command_event.id.to_bytes().to_vec(); + let atomic_conflict = insert_command_event( + &pool, + community, + &command_event, + UserGroupMutation::Create { + group_id: Uuid::new_v4(), + handle: "ios-team".to_owned(), + name: "Conflicting Team".to_owned(), + description: None, + members: Vec::new(), + default_channels: Vec::new(), + created_by: creator.clone(), + }, + ) + .await + .expect_err("atomic handle conflict"); + assert!(matches!( + atomic_conflict, + DbError::UserGroupHandleConflict(ref handle) if handle == "ios-team" + )); + let stored_command_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(command_id) + .fetch_one(&pool) + .await + .expect("query rolled-back command"); + assert_eq!(stored_command_count, 0); + + create_group( + &pool, + community, + Uuid::new_v4(), + "other-team", + "Other Team", + None, + &creator, + &[], + &[channel_b], + ) + .await + .expect("create rival group"); + let rename_conflict = update_group( + &pool, + community, + group_id, + UserGroupUpdate { + handle: Some("other-team".to_owned()), + name: Some("Must Roll Back".to_owned()), + default_channels: Some(vec![channel_b]), + ..UserGroupUpdate::default() + }, + ) + .await + .expect_err("rename to active handle must conflict"); + assert!(matches!( + rename_conflict, + DbError::UserGroupHandleConflict(ref handle) if handle == "other-team" + )); + assert_eq!( + get_group_by_id(&pool, community, group_id) + .await + .expect("group survives failed rename") + .name, + "iOS Team" + ); + assert_eq!( + list_default_channels(&pool, community, group_id) + .await + .expect("defaults survive failed rename"), + vec![channel_a] + ); + + let added = add_members( + &pool, + community, + group_id, + &[pubkey('c'), pubkey('d')], + &creator, + ) + .await + .expect("add members"); + assert_eq!(added.added_pubkeys, vec![pubkey('c'), pubkey('d')]); + assert_eq!(added.default_channels, vec![channel_a]); + assert!(added.snapshot_version > created.snapshot_version); + assert_eq!( + remove_members(&pool, community, group_id, &[pubkey('b')]) + .await + .expect("remove member") + .removed, + 1 + ); + assert_eq!( + list_members(&pool, community, group_id) + .await + .expect("list members") + .len(), + 2 + ); + + update_group( + &pool, + community, + group_id, + UserGroupUpdate { + name: Some("Apple Platforms".to_owned()), + description: Some(None), + default_channels: Some(vec![channel_b]), + ..UserGroupUpdate::default() + }, + ) + .await + .expect("update group"); + assert_eq!( + list_default_channels(&pool, community, group_id) + .await + .expect("list default channels"), + vec![channel_b] + ); + update_group( + &pool, + community, + group_id, + UserGroupUpdate { + default_channels: Some(Vec::new()), + ..UserGroupUpdate::default() + }, + ) + .await + .expect("clear default channels"); + assert!(list_default_channels(&pool, community, group_id) + .await + .expect("list cleared default channels") + .is_empty()); + + assert!(soft_delete_group(&pool, community, group_id) + .await + .expect("soft delete") + .is_some()); + assert!(matches!( + get_group_by_id(&pool, community, group_id).await, + Err(DbError::UserGroupNotFound(id)) if id == group_id + )); + + let reused_id = create_group( + &pool, + community, + group_id, + "reused-id", + "Reused ID", + None, + &creator, + &[], + &[], + ) + .await + .expect_err("soft-deleted ids remain reserved"); + assert!(matches!( + reused_id, + DbError::UserGroupIdConflict(id) if id == group_id + )); + + create_group( + &pool, + community, + Uuid::new_v4(), + "ios-team", + "Recreated iOS Team", + None, + &creator, + &[], + &[], + ) + .await + .expect("soft deletion releases handle"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn groups_are_confined_to_their_community() { + let pool = setup_pool().await; + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let shared_id = Uuid::new_v4(); + let creator = pubkey('e'); + + for community in [community_a, community_b] { + create_group( + &pool, + community, + shared_id, + "shared-handle", + "Shared", + None, + &creator, + &[], + &[], + ) + .await + .expect("same id and handle may exist in separate communities"); + } + + assert_eq!( + list_groups(&pool, community_a) + .await + .expect("list community A") + .iter() + .filter(|group| group.id == shared_id) + .count(), + 1 + ); + assert_eq!( + list_groups(&pool, community_b) + .await + .expect("list community B") + .iter() + .filter(|group| group.id == shared_id) + .count(), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cross_community_default_channel_failure_rolls_back_parent_and_update() { + let pool = setup_pool().await; + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let channel_a = make_channel(&pool, community_a).await; + let channel_b = make_channel(&pool, community_b).await; + let creator = pubkey('f'); + let failed_group_id = Uuid::new_v4(); + + let create_error = create_group( + &pool, + community_a, + failed_group_id, + "foreign-default", + "Foreign Default", + None, + &creator, + &[pubkey('1')], + &[channel_b], + ) + .await + .expect_err("cross-community default channel must fail"); + assert!(matches!(create_error, DbError::Sqlx(_))); + assert!(matches!( + get_group_by_id(&pool, community_a, failed_group_id).await, + Err(DbError::UserGroupNotFound(id)) if id == failed_group_id + )); + + let group_id = Uuid::new_v4(); + create_group( + &pool, + community_a, + group_id, + "local-default", + "Original Name", + None, + &creator, + &[], + &[channel_a], + ) + .await + .expect("create local group"); + let update_error = update_group( + &pool, + community_a, + group_id, + UserGroupUpdate { + name: Some("Must Roll Back".to_owned()), + default_channels: Some(vec![channel_b]), + ..UserGroupUpdate::default() + }, + ) + .await + .expect_err("cross-community replacement must fail"); + assert!(matches!(update_error, DbError::Sqlx(_))); + assert_eq!( + get_group_by_id(&pool, community_a, group_id) + .await + .expect("group survives failed update") + .name, + "Original Name" + ); + assert_eq!( + list_default_channels(&pool, community_a, group_id) + .await + .expect("defaults survive failed update"), + vec![channel_a] + ); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..fc84568d81 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -19,17 +19,18 @@ use buzz_core::kind::{ KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_GROUP_ADD_MEMBER, KIND_GROUP_CREATE, KIND_GROUP_DELETE, KIND_GROUP_EDIT, + KIND_GROUP_REMOVE_MEMBER, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -192,6 +193,17 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { } } +const fn is_user_group_command_kind(kind: u32) -> bool { + matches!( + kind, + KIND_GROUP_CREATE + | KIND_GROUP_EDIT + | KIND_GROUP_DELETE + | KIND_GROUP_ADD_MEMBER + | KIND_GROUP_REMOVE_MEMBER + ) +} + /// Determine the required scope for a given event kind. /// /// Returns `Err` for unknown kinds — the relay rejects them. @@ -277,6 +289,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), + kind if is_user_group_command_kind(kind) => Ok(Scope::ChannelsWrite), KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => { Ok(Scope::ChannelsRead) } @@ -431,6 +444,13 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_MODERATION_TIMEOUT | KIND_MODERATION_UNTIMEOUT | KIND_MODERATION_RESOLVE_REPORT + // User-group commands are community-global. Fine-grained + // creator/admin authorization lives in side_effects.rs. + | KIND_GROUP_CREATE + | KIND_GROUP_EDIT + | KIND_GROUP_DELETE + | KIND_GROUP_ADD_MEMBER + | KIND_GROUP_REMOVE_MEMBER // NIP-43: relay admin commands and leave requests are global — they // must never be channel-scoped, even if the event carries a stray `h` tag. | RELAY_ADMIN_ADD_MEMBER @@ -1908,6 +1928,33 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + let validated_user_group_command = if is_user_group_command_kind(kind_u32) { + match crate::handlers::side_effects::validate_user_group_command(tenant, state, &event) + .await + { + Ok(command) => Some(command), + Err(crate::handlers::side_effects::UserGroupCommandValidationError::Duplicate( + message, + )) => { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message, + }); + } + Err(crate::handlers::side_effects::UserGroupCommandValidationError::Rejected( + message, + )) => return Err(IngestError::Rejected(message)), + Err(crate::handlers::side_effects::UserGroupCommandValidationError::Internal) => { + return Err(IngestError::Internal( + "error: internal user-group validation failure".into(), + )); + } + } + } else { + None + }; + // Processed here (verify consent, mutate archived_identities, emit the // relay-signed 8002/8003 delta + 13535 snapshot), then — unlike the // NIP-43 admin commands above — the request itself falls through to normal @@ -2364,7 +2411,38 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + let mut user_group_mutation_result = None; + let (stored_event, was_inserted) = if let Some(command) = validated_user_group_command { + let mutation = crate::handlers::side_effects::user_group_mutation(&event, command); + let result = match state + .db + .insert_user_group_command_event(tenant.community(), &event, mutation) + .await + { + Ok(result) => result, + Err(error) => { + return match crate::handlers::side_effects::map_user_group_db_error(error) { + crate::handlers::side_effects::UserGroupCommandValidationError::Duplicate( + message, + ) => Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message, + }), + crate::handlers::side_effects::UserGroupCommandValidationError::Rejected( + message, + ) => Err(IngestError::Rejected(message)), + crate::handlers::side_effects::UserGroupCommandValidationError::Internal => { + Err(IngestError::Internal( + "error: internal user-group command failure".into(), + )) + } + }; + } + }; + user_group_mutation_result = result.mutation; + (result.stored_event, result.was_inserted) + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -2431,7 +2509,12 @@ async fn ingest_event_inner( }); } - if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + if let Some(mutation) = user_group_mutation_result { + crate::handlers::side_effects::handle_user_group_post_commit( + tenant, state, &event, mutation, + ) + .await; + } else if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await @@ -2716,6 +2799,26 @@ mod tests { } } + #[test] + fn user_group_commands_are_global_and_require_channels_write() { + let dummy = make_dummy_event(); + for kind in [ + KIND_GROUP_CREATE, + KIND_GROUP_EDIT, + KIND_GROUP_DELETE, + KIND_GROUP_ADD_MEMBER, + KIND_GROUP_REMOVE_MEMBER, + ] { + assert!(is_user_group_command_kind(kind)); + assert_eq!( + required_scope_for_kind(kind, &dummy).expect("scope"), + Scope::ChannelsWrite + ); + assert!(is_global_only_kind(kind)); + assert!(!requires_h_channel_scope(kind)); + } + } + #[test] fn moderation_command_rejection_from_ingest_preserves_prefix() { let rejection = "restricted: moderator access required".to_string(); @@ -2779,6 +2882,11 @@ mod tests { KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNTIMEOUT, KIND_MODERATION_RESOLVE_REPORT, + KIND_GROUP_CREATE, + KIND_GROUP_EDIT, + KIND_GROUP_DELETE, + KIND_GROUP_ADD_MEMBER, + KIND_GROUP_REMOVE_MEMBER, KIND_STREAM_MESSAGE, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index ecc3ba6777..1ebac19495 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1483,6 +1483,16 @@ mod tests { assert!(p_gated_filters_authorized(&[member_notif_ids], authed)); } + #[test] + fn user_group_state_does_not_require_p_tag() { + let authed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let filter = Filter::new().kind(nostr::Kind::Custom( + buzz_core::kind::KIND_GROUP_STATE as u16, + )); + + assert!(p_gated_filters_authorized(&[filter], authed)); + } + /// NIP-AM: kind 44200 must deny `{kinds:[44200], ids:[...]}` by non-owner. /// Thufir's implementation note: the helper treats explicit-kind+ids and /// kindless ids differently. Explicit `{kinds:[44200], ids:[...]}` is denied; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 3112e9a559..d1635ed56d 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1,5 +1,6 @@ //! NIP-29 and NIP-25 side-effect handlers. +use std::collections::HashSet; use std::sync::Arc; use nostr::{Event, EventBuilder, Kind, Tag}; @@ -8,13 +9,16 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, - KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GROUP_ADD_MEMBER, KIND_GROUP_CREATE, KIND_GROUP_DELETE, + KIND_GROUP_EDIT, KIND_GROUP_REMOVE_MEMBER, KIND_GROUP_STATE, KIND_IA_ARCHIVED, + KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, + KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; +use buzz_db::user_group::{UserGroupMutation, UserGroupMutationResult, UserGroupUpdate}; +use buzz_db::DbError; use super::event::dispatch_persistent_event; use crate::protocol::RelayMessage; @@ -327,41 +331,17 @@ pub async fn validate_admin_event( let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; - // Self-add: always allowed regardless of policy. - if target_pubkey == actor_bytes { - return Ok(()); - } - - // Third-party add: check channel_add_policy on the target. - if let Some((policy, owner)) = state + let policy = state .db .get_agent_channel_policy(tenant.community(), &target_pubkey) - .await? - { - match policy.as_str() { - "owner_only" => { - let owner_bytes = owner.ok_or_else(|| { - anyhow::anyhow!("policy:owner_only — agent has no owner set") - })?; - if actor_bytes != owner_bytes { - return Err(anyhow::anyhow!( - "policy:owner_only — only the agent owner can add this agent" - )); - } - } - "nobody" => { - return Err(anyhow::anyhow!( - "policy:nobody — this agent has disabled external channel additions" - )); - } - // "anyone" or any unknown value → allow. - // NOTE: DB ENUM constraint prevents unknown values from being stored. - // If a new policy value is added to the ENUM, update this match. - _ => {} - } - } - - Ok(()) + .await?; + validate_channel_add_policy( + &target_pubkey, + &actor_bytes, + policy + .as_ref() + .map(|(policy, owner)| (policy.as_str(), owner.as_deref())), + ) } 9001 => { // REMOVE_USER: self-remove allowed unless actor is the last owner; removing others requires owner/admin @@ -673,6 +653,29 @@ pub async fn validate_admin_event( } } +fn validate_channel_add_policy( + target_pubkey: &[u8], + actor_pubkey: &[u8], + policy: Option<(&str, Option<&[u8]>)>, +) -> anyhow::Result<()> { + if target_pubkey == actor_pubkey { + return Ok(()); + } + match policy { + Some(("owner_only", Some(owner))) if actor_pubkey == owner => Ok(()), + Some(("owner_only", Some(_))) => Err(anyhow::anyhow!( + "policy:owner_only — only the agent owner can add this agent" + )), + Some(("owner_only", None)) => Err(anyhow::anyhow!( + "policy:owner_only — agent has no owner set" + )), + Some(("nobody", _)) => Err(anyhow::anyhow!( + "policy:nobody — this agent has disabled external channel additions" + )), + _ => Ok(()), + } +} + /// Emit a system message (kind 40099) signed by the relay keypair. pub async fn emit_system_message( tenant: &TenantContext, @@ -951,6 +954,778 @@ async fn emit_addressable_discovery_event( Ok(()) } +/// Sign, store (replacing previous), and fan out one global NIP-33 snapshot. +async fn emit_parameterized_snapshot( + tenant: &TenantContext, + state: &Arc, + kind: u32, + d_tag: &str, + tags: Vec, +) -> anyhow::Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts = { + let existing = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![kind as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + d_tag: Some(d_tag.to_string()), + limit: Some(1), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await + .unwrap_or_default(); + existing + .first() + .map(|stored| (stored.event.created_at.as_secs() + 1).max(now)) + .unwrap_or(now) + }; + emit_parameterized_snapshot_at(tenant, state, kind, d_tag, tags, ts).await +} + +async fn emit_parameterized_snapshot_at( + tenant: &TenantContext, + state: &Arc, + kind: u32, + d_tag: &str, + tags: Vec, + created_at: u64, +) -> anyhow::Result { + let event = EventBuilder::new(Kind::Custom(kind as u16), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&state.relay_keypair) + .map_err(|error| anyhow::anyhow!("failed to sign kind:{kind}: {error}"))?; + let (stored, was_inserted) = state + .db + .replace_parameterized_event(tenant.community(), &event, d_tag, None) + .await?; + if was_inserted { + dispatch_persistent_event( + tenant, + state, + &stored, + kind, + &state.relay_keypair.public_key().to_hex(), + None, + ) + .await; + } + Ok(was_inserted) +} + +#[derive(Debug)] +/// Failure modes from user-group command validation or application. +pub(crate) enum UserGroupCommandValidationError { + /// A group ID or handle already belongs to an active group. + Duplicate(String), + /// The command is malformed or the actor is not authorized. + Rejected(String), + /// The operation could not complete because an internal dependency failed. + Internal, +} + +#[derive(Debug)] +pub(crate) enum ValidatedUserGroupCommand { + Create { + group_id: Uuid, + handle: String, + name: String, + description: Option, + members: Vec, + default_channels: Vec, + }, + Edit { + group_id: Uuid, + updates: UserGroupUpdate, + }, + Delete { + group_id: Uuid, + }, + AddMembers { + group_id: Uuid, + members: Vec, + }, + RemoveMembers { + group_id: Uuid, + members: Vec, + }, +} + +/// Validate a user-group command before its signed event is stored. +/// +/// The preflight performs parsing, authorization, and dependency validation. +/// Event storage and the SQL mutation commit atomically after this returns. +pub(crate) async fn validate_user_group_command( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result { + let command = + parse_user_group_command(event).map_err(UserGroupCommandValidationError::Rejected)?; + let actor_hex = event.pubkey.to_hex(); + + match &command { + ValidatedUserGroupCommand::Create { + members, + default_channels, + .. + } => { + // Edit/delete/membership commands gate the actor via the + // creator-or-admin rule; create has no group to load, so the actor + // must pass the same community admission as member arguments. + validate_group_members(tenant, state, std::slice::from_ref(&actor_hex)) + .await + .map_err(|error| match error { + UserGroupCommandValidationError::Rejected(_) => { + UserGroupCommandValidationError::Rejected( + "restricted: only community members may create user groups".into(), + ) + } + other => other, + })?; + validate_group_members(tenant, state, members).await?; + validate_group_default_channels(tenant, state, default_channels).await?; + } + ValidatedUserGroupCommand::Edit { group_id, updates } => { + load_authorized_user_group(tenant, state, *group_id, &actor_hex).await?; + if let Some(channel_ids) = updates.default_channels.as_deref() { + validate_group_default_channels(tenant, state, channel_ids).await?; + } + } + ValidatedUserGroupCommand::Delete { group_id } => { + load_authorized_user_group(tenant, state, *group_id, &actor_hex).await?; + } + ValidatedUserGroupCommand::AddMembers { group_id, members } => { + load_authorized_user_group(tenant, state, *group_id, &actor_hex).await?; + validate_group_members(tenant, state, members).await?; + } + ValidatedUserGroupCommand::RemoveMembers { group_id, .. } => { + load_authorized_user_group(tenant, state, *group_id, &actor_hex).await?; + } + } + + Ok(command) +} + +async fn load_authorized_user_group( + tenant: &TenantContext, + state: &Arc, + group_id: Uuid, + actor_hex: &str, +) -> Result { + let group = state + .db + .get_user_group_by_id(tenant.community(), group_id) + .await + .map_err(|error| match error { + DbError::UserGroupNotFound(_) => { + UserGroupCommandValidationError::Rejected("invalid: user group not found".into()) + } + other => user_group_internal(other), + })?; + let community_role = state + .db + .get_relay_member(tenant.community(), actor_hex) + .await + .map_err(user_group_internal)? + .map(|member| member.role); + + if user_group_actor_can_manage(&group.created_by, actor_hex, community_role.as_deref()) { + Ok(group) + } else { + Err(UserGroupCommandValidationError::Rejected( + "restricted: only the group creator or a community admin/owner may modify this group" + .into(), + )) + } +} + +fn user_group_internal(error: impl std::fmt::Display) -> UserGroupCommandValidationError { + warn!(%error, "user-group command validation failed"); + UserGroupCommandValidationError::Internal +} + +pub(crate) fn user_group_mutation( + event: &Event, + command: ValidatedUserGroupCommand, +) -> UserGroupMutation { + let actor_hex = event.pubkey.to_hex(); + match command { + ValidatedUserGroupCommand::Create { + group_id, + handle, + name, + description, + members, + default_channels, + } => UserGroupMutation::Create { + group_id, + handle, + name, + description, + members, + default_channels, + created_by: actor_hex, + }, + ValidatedUserGroupCommand::Edit { group_id, updates } => { + UserGroupMutation::Edit { group_id, updates } + } + ValidatedUserGroupCommand::Delete { group_id } => UserGroupMutation::Delete { group_id }, + ValidatedUserGroupCommand::AddMembers { group_id, members } => { + UserGroupMutation::AddMembers { + group_id, + members, + added_by: actor_hex, + } + } + ValidatedUserGroupCommand::RemoveMembers { group_id, members } => { + UserGroupMutation::RemoveMembers { group_id, members } + } + } +} + +pub(crate) async fn handle_user_group_post_commit( + tenant: &TenantContext, + state: &Arc, + event: &Event, + mutation: UserGroupMutationResult, +) { + let actor_hex = event.pubkey.to_hex(); + let actor_bytes = event.pubkey.as_bytes(); + + match mutation { + UserGroupMutationResult::Created { + group_id, + handle, + snapshot_version, + } => { + publish_user_group_snapshot_best_effort(tenant, state, group_id, snapshot_version) + .await; + info!(group = %group_id, handle, actor = %actor_hex, "user group created"); + } + UserGroupMutationResult::Edited { + group_id, + snapshot_version, + } => { + publish_user_group_snapshot_best_effort(tenant, state, group_id, snapshot_version) + .await; + info!(group = %group_id, actor = %actor_hex, "user group edited"); + } + UserGroupMutationResult::Deleted { + group_id, + snapshot_version, + } => { + publish_user_group_tombstone_best_effort(tenant, state, group_id, snapshot_version) + .await; + info!(group = %group_id, actor = %actor_hex, "user group deleted"); + } + UserGroupMutationResult::MembersAdded { group_id, result } => { + publish_user_group_snapshot_best_effort( + tenant, + state, + group_id, + result.snapshot_version, + ) + .await; + + let member_bytes = result + .added_pubkeys + .iter() + .filter_map(|member| match hex::decode(member) { + Ok(bytes) => Some(bytes), + Err(_) => { + warn!(group = %group_id, member, "stored user-group member pubkey is invalid"); + None + } + }) + .collect::>(); + let facts = match state + .db + .get_community_member_facts(tenant.community(), &member_bytes) + .await + { + Ok(facts) => facts, + Err(error) => { + warn!(group = %group_id, %error, "failed to load channel-add policies"); + Vec::new() + } + }; + let allowed_members = facts + .into_iter() + .filter(|facts| { + validate_channel_add_policy( + &facts.pubkey, + actor_bytes, + Some((&facts.channel_add_policy, facts.agent_owner.as_deref())), + ) + .is_ok() + }) + .map(|facts| facts.pubkey) + .collect::>(); + + for channel_id in &result.default_channels { + let channel = match state.db.get_channel(tenant.community(), *channel_id).await { + Ok(channel) + if channel.visibility == "open" && channel.archived_at.is_none() => + { + channel + } + Ok(_) | Err(DbError::ChannelNotFound(_)) => continue, + Err(error) => { + warn!(group = %group_id, channel = %channel_id, %error, "failed to load default channel"); + continue; + } + }; + let existing = match state.db.get_members(tenant.community(), channel.id).await { + Ok(members) => members + .into_iter() + .map(|member| member.pubkey) + .collect::>(), + Err(error) => { + warn!(group = %group_id, channel = %channel_id, %error, "failed to load channel members"); + continue; + } + }; + let mut changed = false; + for member in &allowed_members { + if existing.contains(member) { + continue; + } + if let Err(error) = add_channel_member( + tenant, + state, + channel.id, + member, + MemberRole::Member, + actor_bytes, + false, + ) + .await + { + warn!( + group = %group_id, + channel = %channel_id, + %error, + "user-group default-channel auto-join failed" + ); + } else { + changed = true; + } + } + if changed { + if let Err(error) = emit_group_discovery_events(tenant, state, channel.id).await + { + warn!(channel = %channel_id, %error, "NIP-29 group discovery emission failed"); + } + } + } + info!( + group = %group_id, + added = result.added_pubkeys.len(), + actor = %actor_hex, + "user-group members added" + ); + } + UserGroupMutationResult::MembersRemoved { group_id, result } => { + publish_user_group_snapshot_best_effort( + tenant, + state, + group_id, + result.snapshot_version, + ) + .await; + info!( + group = %group_id, + removed = result.removed, + actor = %actor_hex, + "user-group members removed" + ); + } + } +} + +fn user_group_actor_can_manage( + created_by: &str, + actor_hex: &str, + community_role: Option<&str>, +) -> bool { + created_by == actor_hex || matches!(community_role, Some("owner" | "admin")) +} + +async fn validate_group_members( + tenant: &TenantContext, + state: &Arc, + members: &[String], +) -> Result<(), UserGroupCommandValidationError> { + let pubkeys = members + .iter() + .map(|member| { + hex::decode(member).map_err(|_| { + UserGroupCommandValidationError::Rejected( + "invalid: member pubkey must be 64 hexadecimal characters".into(), + ) + }) + }) + .collect::, _>>()?; + let facts = state + .db + .get_community_member_facts(tenant.community(), &pubkeys) + .await + .map_err(user_group_internal)?; + for facts in facts { + let admitted = if state.config.require_relay_membership { + facts.relay_member || (state.config.allow_nip_oa_auth && facts.owner_relay_member) + } else { + facts.user_exists + }; + if !admitted { + let member = hex::encode(facts.pubkey); + return Err(UserGroupCommandValidationError::Rejected(format!( + "invalid: member pubkey is not a community member: {member}" + ))); + } + } + Ok(()) +} + +async fn validate_group_default_channels( + tenant: &TenantContext, + state: &Arc, + channel_ids: &[Uuid], +) -> Result<(), UserGroupCommandValidationError> { + for channel_id in channel_ids { + let channel = state + .db + .get_channel(tenant.community(), *channel_id) + .await + .map_err(|error| match error { + DbError::ChannelNotFound(_) => UserGroupCommandValidationError::Rejected(format!( + "invalid: default channel not found: {channel_id}" + )), + other => user_group_internal(other), + })?; + if channel.visibility != "open" || channel.archived_at.is_some() { + return Err(UserGroupCommandValidationError::Rejected(format!( + "invalid: default channel must be public and active: {channel_id}" + ))); + } + } + Ok(()) +} + +fn parse_user_group_command(event: &Event) -> Result { + if !event.content.is_empty() { + return Err("invalid: user-group command content must be empty".into()); + } + + let group_id = extract_group_id(event)?; + match event_kind_u32(event) { + KIND_GROUP_CREATE => { + let handle = required_single_tag(event, "handle")?; + if !buzz_core::user_group::is_valid_group_handle(&handle) { + return Err("invalid: group handle must match ^[a-z0-9][a-z0-9_-]{1,31}$".into()); + } + let name = required_single_tag(event, "name")?; + validate_group_name(&name)?; + let description = + optional_single_tag(event, "description")?.filter(|value| !value.is_empty()); + let members = extract_group_member_pubkeys(event, false)?; + let default_channels = + extract_group_default_channels(event, false)?.unwrap_or_default(); + Ok(ValidatedUserGroupCommand::Create { + group_id, + handle, + name, + description, + members, + default_channels, + }) + } + KIND_GROUP_EDIT => { + let handle = optional_single_tag(event, "handle")?; + if handle + .as_deref() + .is_some_and(|value| !buzz_core::user_group::is_valid_group_handle(value)) + { + return Err("invalid: group handle must match ^[a-z0-9][a-z0-9_-]{1,31}$".into()); + } + let name = optional_single_tag(event, "name")?; + if let Some(value) = name.as_deref() { + validate_group_name(value)?; + } + let description = optional_single_tag(event, "description")? + .map(|value| (!value.is_empty()).then_some(value)); + let default_channels = extract_group_default_channels(event, true)?; + if handle.is_none() + && name.is_none() + && description.is_none() + && default_channels.is_none() + { + return Err("invalid: group edit must include at least one field".into()); + } + Ok(ValidatedUserGroupCommand::Edit { + group_id, + updates: UserGroupUpdate { + handle, + name, + description, + default_channels, + }, + }) + } + KIND_GROUP_DELETE => Ok(ValidatedUserGroupCommand::Delete { group_id }), + KIND_GROUP_ADD_MEMBER => Ok(ValidatedUserGroupCommand::AddMembers { + group_id, + members: extract_group_member_pubkeys(event, true)?, + }), + KIND_GROUP_REMOVE_MEMBER => Ok(ValidatedUserGroupCommand::RemoveMembers { + group_id, + members: extract_group_member_pubkeys(event, true)?, + }), + kind => Err(format!( + "invalid: unexpected user-group command kind: {kind}" + )), + } +} + +fn extract_group_id(event: &Event) -> Result { + let value = required_single_tag(event, "g")?; + let group_id = + Uuid::parse_str(&value).map_err(|_| "invalid: g tag must contain a UUID".to_string())?; + if group_id.is_nil() { + return Err("invalid: group id must not be nil".to_string()); + } + Ok(group_id) +} + +fn required_single_tag(event: &Event, tag_name: &str) -> Result { + optional_single_tag(event, tag_name)?.ok_or_else(|| format!("invalid: missing {tag_name} tag")) +} + +fn optional_single_tag(event: &Event, tag_name: &str) -> Result, String> { + let tags: Vec<&Tag> = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == tag_name) + .collect(); + match tags.as_slice() { + [] => Ok(None), + [tag] => tag + .content() + .map(|value| Some(value.to_string())) + .ok_or_else(|| format!("invalid: {tag_name} tag must contain a value")), + _ => Err(format!("invalid: {tag_name} tag must appear at most once")), + } +} + +fn validate_group_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + Err("invalid: group name is required".to_string()) + } else { + Ok(()) + } +} + +fn extract_group_member_pubkeys(event: &Event, required: bool) -> Result, String> { + let mut members = Vec::new(); + for tag in event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "p") + { + if members.len() == buzz_core::user_group::MAX_USER_GROUP_MEMBERS { + return Err(format!( + "invalid: user-group commands support at most {} member pubkeys", + buzz_core::user_group::MAX_USER_GROUP_MEMBERS + )); + } + let value = tag + .content() + .ok_or_else(|| "invalid: p tag must contain a pubkey".to_string())?; + if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) { + return Err("invalid: member pubkey must be 64 hexadecimal characters".to_string()); + } + members.push(value.to_ascii_lowercase()); + } + members.sort_unstable(); + members.dedup(); + if required && members.is_empty() { + return Err("invalid: at least one member pubkey is required".to_string()); + } + Ok(members) +} + +fn extract_group_default_channels( + event: &Event, + allow_clear: bool, +) -> Result>, String> { + let tags: Vec<&Tag> = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "channel") + .collect(); + if tags.len() > buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS { + return Err(format!( + "invalid: user groups support at most {} default channels", + buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + )); + } + if tags.is_empty() { + return Ok(None); + } + let values = tags + .iter() + .map(|tag| { + tag.content() + .ok_or_else(|| "invalid: channel tag must contain a value".to_string()) + }) + .collect::, _>>()?; + if values.iter().any(|value| value.is_empty()) { + if allow_clear && values.len() == 1 { + return Ok(Some(Vec::new())); + } + return Err("invalid: empty channel tag must be the only channel tag".to_string()); + } + + let mut channel_ids = values + .into_iter() + .map(|value| { + Uuid::parse_str(value) + .map_err(|_| format!("invalid: channel tag must contain a UUID: {value}")) + }) + .collect::, _>>()?; + channel_ids.sort_unstable(); + channel_ids.dedup(); + Ok(Some(channel_ids)) +} + +pub(crate) fn map_user_group_db_error(error: DbError) -> UserGroupCommandValidationError { + match error { + DbError::UserGroupHandleConflict(handle) => UserGroupCommandValidationError::Duplicate( + format!("duplicate: user group handle already exists: {handle}"), + ), + DbError::UserGroupIdConflict(group_id) => UserGroupCommandValidationError::Duplicate( + format!("duplicate: user group id already used: {group_id}"), + ), + DbError::UserGroupMemberLimit(limit) => UserGroupCommandValidationError::Rejected(format!( + "invalid: user groups support at most {limit} members" + )), + DbError::UserGroupNotFound(_) => { + UserGroupCommandValidationError::Rejected("invalid: user group not found".into()) + } + other => user_group_internal(other), + } +} + +async fn publish_user_group_snapshot_best_effort( + tenant: &TenantContext, + state: &Arc, + group_id: Uuid, + snapshot_version: i64, +) { + if let Err(first_error) = + publish_user_group_snapshot(tenant, state, group_id, snapshot_version).await + { + if let Err(error) = + publish_user_group_snapshot(tenant, state, group_id, snapshot_version).await + { + warn!(group = %group_id, %first_error, retry_error = %error, "user-group snapshot publication failed"); + } + } +} + +async fn publish_user_group_tombstone_best_effort( + tenant: &TenantContext, + state: &Arc, + group_id: Uuid, + snapshot_version: i64, +) { + let d_tag = group_id.to_string(); + let tags = match ( + Tag::parse(["d", &d_tag]), + Tag::parse(["deleted"]), + u64::try_from(snapshot_version), + ) { + (Ok(d), Ok(deleted), Ok(version)) => (vec![d, deleted], version), + _ => { + warn!(group = %group_id, "failed to build user-group tombstone"); + return; + } + }; + if let Err(first_error) = emit_parameterized_snapshot_at( + tenant, + state, + KIND_GROUP_STATE, + &d_tag, + tags.0.clone(), + tags.1, + ) + .await + { + if let Err(error) = + emit_parameterized_snapshot_at(tenant, state, KIND_GROUP_STATE, &d_tag, tags.0, tags.1) + .await + { + warn!(group = %group_id, %first_error, retry_error = %error, "user-group tombstone publication failed"); + } + } +} + +async fn publish_user_group_snapshot( + tenant: &TenantContext, + state: &Arc, + group_id: Uuid, + snapshot_version: i64, +) -> anyhow::Result<()> { + let d_tag = group_id.to_string(); + let tags = build_user_group_snapshot(tenant, state, group_id).await?; + let created_at = u64::try_from(snapshot_version)?; + let _ = + emit_parameterized_snapshot_at(tenant, state, KIND_GROUP_STATE, &d_tag, tags, created_at) + .await?; + Ok(()) +} + +async fn build_user_group_snapshot( + tenant: &TenantContext, + state: &Arc, + group_id: Uuid, +) -> anyhow::Result> { + let group = state + .db + .get_user_group_by_id(tenant.community(), group_id) + .await?; + let members = state + .db + .list_user_group_members(tenant.community(), group_id) + .await?; + let default_channels = state + .db + .list_user_group_default_channels(tenant.community(), group_id) + .await?; + let group_id = group_id.to_string(); + let mut tags = Vec::with_capacity(members.len() + default_channels.len() + 6); + tags.push(Tag::parse(["d", &group_id])?); + tags.push(Tag::parse(["handle", &group.handle])?); + tags.push(Tag::parse(["name", &group.name])?); + tags.push(Tag::parse([ + "description", + group.description.as_deref().unwrap_or(""), + ])?); + tags.push(Tag::parse(["creator", &group.created_by])?); + for member in &members { + tags.push(Tag::parse(["p", &member.pubkey])?); + } + for channel_id in &default_channels { + tags.push(Tag::parse(["channel", &channel_id.to_string()])?); + } + Ok(tags) +} + /// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair. /// Called after group creation, metadata changes, or membership changes. /// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing @@ -1214,21 +1989,41 @@ async fn handle_put_user( .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?; let actor_bytes = event.pubkey.to_bytes().to_vec(); + add_channel_member( + tenant, + state, + channel_id, + &target_pubkey, + role, + &actor_bytes, + true, + ) + .await +} +async fn add_channel_member( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + target_pubkey: &[u8], + role: MemberRole, + actor_bytes: &[u8], + refresh_discovery: bool, +) -> anyhow::Result<()> { state .db .add_member( tenant.community(), channel_id, - &target_pubkey, + target_pubkey, role, - Some(&actor_bytes), + Some(actor_bytes), ) .await?; - state.invalidate_membership(tenant, channel_id, &target_pubkey); + state.invalidate_membership(tenant, channel_id, target_pubkey); - let actor_hex = hex::encode(&actor_bytes); - let target_hex = hex::encode(&target_pubkey); + let actor_hex = hex::encode(actor_bytes); + let target_hex = hex::encode(target_pubkey); emit_system_message( tenant, state, @@ -1241,16 +2036,18 @@ async fn handle_put_user( ) .await?; - if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { - warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); + if refresh_discovery { + if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { + warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); + } } if let Err(e) = emit_membership_notification( tenant, state, channel_id, - &target_pubkey, - &actor_bytes, + target_pubkey, + actor_bytes, KIND_MEMBER_ADDED_NOTIFICATION, ) .await @@ -3062,7 +3859,6 @@ pub async fn publish_dm_visibility_snapshot( ) -> anyhow::Result<()> { let viewer_hex = hex::encode(viewer); let hidden = state.db.list_hidden_dms(tenant.community(), viewer).await?; - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); let mut tags: Vec = Vec::with_capacity(hidden.len() + 2); tags.push( @@ -3082,53 +3878,7 @@ pub async fn publish_dm_visibility_snapshot( ); } - // Force created_at strictly past any prior snapshot for this viewer: a same-second - // replacement whose random event id sorts higher is rejected by stale-write - // protection, so a hide→re-open within one second could otherwise strand the stale - // snapshot. Same guard as emit_addressable_discovery_event. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let ts = { - let existing = state - .db - .query_events(&buzz_db::event::EventQuery { - kinds: Some(vec![KIND_DM_VISIBILITY as i32]), - pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), - d_tag: Some(viewer_hex.clone()), - limit: Some(1), - ..buzz_db::event::EventQuery::for_community(tenant.community()) - }) - .await - .unwrap_or_default(); - existing - .first() - .map(|e| (e.event.created_at.as_secs() + 1).max(now)) - .unwrap_or(now) - }; - - let event = EventBuilder::new(Kind::Custom(KIND_DM_VISIBILITY as u16), "") - .tags(tags) - .custom_created_at(nostr::Timestamp::from(ts)) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_DM_VISIBILITY}: {e}"))?; - - let (stored, was_inserted) = state - .db - .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None) - .await?; - if was_inserted { - dispatch_persistent_event( - tenant, - state, - &stored, - KIND_DM_VISIBILITY, - &relay_pubkey_hex, - None, - ) - .await; - } + emit_parameterized_snapshot(tenant, state, KIND_DM_VISIBILITY, &viewer_hex, tags).await?; info!( viewer = %viewer_hex, @@ -3267,6 +4017,169 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + fn group_event(kind: u32, tags: Vec) -> Event { + EventBuilder::new(Kind::Custom(kind as u16), "") + .tags(tags) + .allow_self_tagging() + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign group event") + } + + #[test] + fn user_group_creator_and_community_admins_can_manage() { + let creator = "aa".repeat(32); + let other = "bb".repeat(32); + + assert!(user_group_actor_can_manage(&creator, &creator, None)); + assert!(user_group_actor_can_manage(&creator, &other, Some("owner"))); + assert!(user_group_actor_can_manage(&creator, &other, Some("admin"))); + assert!(!user_group_actor_can_manage( + &creator, + &other, + Some("member") + )); + assert!(!user_group_actor_can_manage(&creator, &other, None)); + } + + #[test] + fn user_group_handle_conflict_maps_to_duplicate_result() { + let error = map_user_group_db_error(DbError::UserGroupHandleConflict("ios-team".into())); + + assert!(matches!( + error, + UserGroupCommandValidationError::Duplicate(message) + if message == "duplicate: user group handle already exists: ios-team" + )); + } + + #[test] + fn channel_add_policy_is_shared_by_direct_and_group_adds() { + let actor = [1_u8; 32]; + let target = [2_u8; 32]; + let owner = [3_u8; 32]; + + assert!(validate_channel_add_policy(&actor, &actor, Some(("nobody", None))).is_ok()); + assert!(validate_channel_add_policy(&target, &actor, Some(("anyone", None))).is_ok()); + assert!(validate_channel_add_policy(&target, &actor, Some(("nobody", None))).is_err()); + assert!( + validate_channel_add_policy(&target, &actor, Some(("owner_only", Some(&owner)))) + .is_err() + ); + assert!( + validate_channel_add_policy(&target, &owner, Some(("owner_only", Some(&owner)))) + .is_ok() + ); + } + + #[test] + fn user_group_command_content_must_be_empty() { + let event = EventBuilder::new(Kind::Custom(KIND_GROUP_DELETE as u16), "not empty") + .tags([Tag::parse(["g", &Uuid::new_v4().to_string()]).expect("g tag")]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign group event"); + + assert!(parse_user_group_command(&event).is_err()); + } + + #[test] + fn user_group_member_validation_normalizes_and_deduplicates_pubkeys() { + let uppercase = "AB".repeat(32); + let event = group_event( + KIND_GROUP_ADD_MEMBER, + vec![ + Tag::parse(["g", &Uuid::new_v4().to_string()]).expect("g tag"), + Tag::parse(["p", &uppercase]).expect("p tag"), + Tag::parse(["p", &uppercase.to_ascii_lowercase()]).expect("p tag"), + ], + ); + + assert_eq!( + extract_group_member_pubkeys(&event, true).expect("members"), + vec![uppercase.to_ascii_lowercase()] + ); + } + + #[test] + fn user_group_member_validation_rejects_missing_and_malformed_pubkeys() { + let group_id = Uuid::new_v4().to_string(); + let missing = group_event( + KIND_GROUP_ADD_MEMBER, + vec![Tag::parse(["g", &group_id]).expect("g tag")], + ); + assert!(extract_group_member_pubkeys(&missing, true).is_err()); + + let malformed = group_event( + KIND_GROUP_ADD_MEMBER, + vec![ + Tag::parse(["g", &group_id]).expect("g tag"), + Tag::parse(["p", "not-a-pubkey"]).expect("p tag"), + ], + ); + assert!(extract_group_member_pubkeys(&malformed, true).is_err()); + } + + #[test] + fn user_group_member_commands_reject_oversized_batches_before_deduplication() { + let group_id = Uuid::new_v4().to_string(); + let mut tags = vec![Tag::parse(["g", &group_id]).expect("g tag")]; + for _ in 0..=buzz_core::user_group::MAX_USER_GROUP_MEMBERS { + tags.push(Tag::parse(["p", &"ab".repeat(32)]).expect("p tag")); + } + let event = group_event(KIND_GROUP_ADD_MEMBER, tags); + + assert!(extract_group_member_pubkeys(&event, true) + .expect_err("oversized batch") + .contains("at most")); + } + + #[test] + fn user_group_default_channel_empty_sentinel_clears_only_by_itself() { + let group_id = Uuid::new_v4().to_string(); + let clear = group_event( + KIND_GROUP_EDIT, + vec![ + Tag::parse(["g", &group_id]).expect("g tag"), + Tag::parse(["channel", ""]).expect("channel tag"), + ], + ); + assert_eq!( + extract_group_default_channels(&clear, true).expect("clear"), + Some(Vec::new()) + ); + + let channel_id = Uuid::new_v4().to_string(); + let mixed = group_event( + KIND_GROUP_EDIT, + vec![ + Tag::parse(["g", &group_id]).expect("g tag"), + Tag::parse(["channel", ""]).expect("channel tag"), + Tag::parse(["channel", &channel_id]).expect("channel tag"), + ], + ); + assert!(extract_group_default_channels(&mixed, true).is_err()); + } + + #[test] + fn user_group_id_requires_one_non_nil_uuid() { + let missing = group_event(KIND_GROUP_DELETE, Vec::new()); + assert!(extract_group_id(&missing).is_err()); + + let nil = group_event( + KIND_GROUP_DELETE, + vec![Tag::parse(["g", &Uuid::nil().to_string()]).expect("g tag")], + ); + assert!(extract_group_id(&nil).is_err()); + + let duplicate = group_event( + KIND_GROUP_DELETE, + vec![ + Tag::parse(["g", &Uuid::new_v4().to_string()]).expect("g tag"), + Tag::parse(["g", &Uuid::new_v4().to_string()]).expect("g tag"), + ], + ); + assert!(extract_group_id(&duplicate).is_err()); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..65f34e80c3 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -9,10 +9,11 @@ use buzz_core::{ KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_GIT_STATUS_OPEN, KIND_GROUP_ADD_MEMBER, KIND_GROUP_CREATE, KIND_GROUP_DELETE, + KIND_GROUP_EDIT, KIND_GROUP_REMOVE_MEMBER, KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -591,6 +592,168 @@ pub fn build_remove_member( Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags)) } +/// Build a user-group create command (kind 47000). +/// +/// A new UUID is generated for the `g` tag. Member pubkeys are validated and +/// normalized to lowercase; `description`, members, and default channels may +/// be omitted. +pub fn build_group_create( + handle: &str, + name: &str, + description: Option<&str>, + members: &[&str], + default_channels: &[Uuid], +) -> Result { + if !buzz_core::user_group::is_valid_group_handle(handle) { + return Err(SdkError::InvalidInput( + "group handle must match ^[a-z0-9][a-z0-9_-]{1,31}$".into(), + )); + } + if members.len() > buzz_core::user_group::MAX_USER_GROUP_MEMBERS { + return Err(SdkError::InvalidInput(format!( + "user groups support at most {} members", + buzz_core::user_group::MAX_USER_GROUP_MEMBERS + ))); + } + if default_channels.len() > buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS { + return Err(SdkError::InvalidInput(format!( + "user groups support at most {} default channels", + buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + ))); + } + + let group_id = Uuid::new_v4(); + let mut tags = vec![ + tag(&["g", &group_id.to_string()])?, + tag(&["handle", handle])?, + tag(&["name", name])?, + ]; + if let Some(value) = description { + tags.push(tag(&["description", value])?); + } + for member in members { + let pubkey = check_pubkey_hex(member, "member pubkey")?; + tags.push(tag(&["p", &pubkey])?); + } + for channel_id in default_channels { + tags.push(tag(&["channel", &channel_id.to_string()])?); + } + + Ok( + EventBuilder::new(Kind::Custom(KIND_GROUP_CREATE as u16), "") + .tags(tags) + .allow_self_tagging(), + ) +} + +/// Build a user-group edit command (kind 47001). +/// +/// Only supplied fields are emitted. `default_channels` replaces the entire +/// list when present; `Some(&[])` emits `["channel", ""]` to clear it. +pub fn build_group_edit( + group_id: Uuid, + handle: Option<&str>, + name: Option<&str>, + description: Option<&str>, + default_channels: Option<&[Uuid]>, +) -> Result { + if handle.is_none() && name.is_none() && description.is_none() && default_channels.is_none() { + return Err(SdkError::InvalidTag( + "at least one group field must be provided".into(), + )); + } + if handle.is_some_and(|value| !buzz_core::user_group::is_valid_group_handle(value)) { + return Err(SdkError::InvalidInput( + "group handle must match ^[a-z0-9][a-z0-9_-]{1,31}$".into(), + )); + } + if default_channels.is_some_and(|channels| { + channels.len() > buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + }) { + return Err(SdkError::InvalidInput(format!( + "user groups support at most {} default channels", + buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + ))); + } + + let mut tags = vec![tag(&["g", &group_id.to_string()])?]; + if let Some(value) = handle { + tags.push(tag(&["handle", value])?); + } + if let Some(value) = name { + tags.push(tag(&["name", value])?); + } + if let Some(value) = description { + tags.push(tag(&["description", value])?); + } + if let Some(channel_ids) = default_channels { + if channel_ids.is_empty() { + tags.push(tag(&["channel", ""])?); + } else { + for channel_id in channel_ids { + tags.push(tag(&["channel", &channel_id.to_string()])?); + } + } + } + + Ok(EventBuilder::new(Kind::Custom(KIND_GROUP_EDIT as u16), "").tags(tags)) +} + +/// Build a user-group delete command (kind 47002). +pub fn build_group_delete(group_id: Uuid) -> Result { + let tags = vec![tag(&["g", &group_id.to_string()])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_GROUP_DELETE as u16), "").tags(tags)) +} + +fn group_membership_tags(group_id: Uuid, pubkeys: &[&str]) -> Result, SdkError> { + if pubkeys.is_empty() { + return Err(SdkError::InvalidTag( + "at least one member pubkey must be provided".into(), + )); + } + if pubkeys.len() > buzz_core::user_group::MAX_USER_GROUP_MEMBERS { + return Err(SdkError::InvalidInput(format!( + "user-group membership commands support at most {} members", + buzz_core::user_group::MAX_USER_GROUP_MEMBERS + ))); + } + + let mut tags = Vec::with_capacity(pubkeys.len() + 1); + tags.push(tag(&["g", &group_id.to_string()])?); + for pubkey in pubkeys { + let pubkey = check_pubkey_hex(pubkey, "member pubkey")?; + tags.push(tag(&["p", &pubkey])?); + } + Ok(tags) +} + +/// Build a user-group add-members command (kind 47003). +/// +/// At least one valid 64-hex member pubkey is required. +pub fn build_group_add_members(group_id: Uuid, pubkeys: &[&str]) -> Result { + let tags = group_membership_tags(group_id, pubkeys)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_GROUP_ADD_MEMBER as u16), "") + .tags(tags) + .allow_self_tagging(), + ) +} + +/// Build a user-group remove-members command (kind 47004). +/// +/// At least one valid 64-hex member pubkey is required. +pub fn build_group_remove_members( + group_id: Uuid, + pubkeys: &[&str], +) -> Result { + let tags = group_membership_tags(group_id, pubkeys)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_GROUP_REMOVE_MEMBER as u16), "") + .tags(tags) + .allow_self_tagging(), + ) +} + /// Build a NIP-29 leave-request event (kind 9022). pub fn build_leave(channel_id: Uuid) -> Result { let tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -2382,6 +2545,155 @@ mod tests { assert!(has_tag(&ev, "p", pubkey)); } + #[test] + fn group_create_emits_exact_command_tags() { + let member_a = "A".repeat(64); + let member_b = "b".repeat(64); + let channels = [uuid(), uuid()]; + let ev = sign( + build_group_create( + "ios-team", + "iOS Team", + Some("Mobile platform"), + &[member_a.as_str(), member_b.as_str()], + &channels, + ) + .unwrap(), + ); + + assert_eq!(ev.kind.as_u16(), KIND_GROUP_CREATE as u16); + assert!(ev.content.is_empty()); + let group_ids = tag_values(&ev, "g"); + assert_eq!(group_ids.len(), 1); + assert!(Uuid::parse_str(&group_ids[0]).is_ok()); + assert_eq!(tag_values(&ev, "handle"), ["ios-team"]); + assert_eq!(tag_values(&ev, "name"), ["iOS Team"]); + assert_eq!(tag_values(&ev, "description"), ["Mobile platform"]); + assert_eq!( + tag_values(&ev, "p"), + [member_a.to_ascii_lowercase(), member_b] + ); + assert_eq!( + tag_values(&ev, "channel"), + channels.map(|channel_id| channel_id.to_string()) + ); + assert!(tag_values(&ev, "h").is_empty()); + } + + #[test] + fn group_create_supports_optional_empty_lists_and_validates_inputs() { + let ev = sign(build_group_create("qa", "QA", None, &[], &[]).unwrap()); + assert!(tag_values(&ev, "description").is_empty()); + assert!(tag_values(&ev, "p").is_empty()); + assert!(tag_values(&ev, "channel").is_empty()); + + assert!(matches!( + build_group_create("-qa", "QA", None, &[], &[]), + Err(SdkError::InvalidInput(_)) + )); + assert!(matches!( + build_group_create("qa", "QA", None, &["not-a-pubkey"], &[]), + Err(SdkError::InvalidInput(_)) + )); + + let members = vec![ + "abababababababababababababababababababababababababababababababab"; + buzz_core::user_group::MAX_USER_GROUP_MEMBERS + 1 + ]; + assert!(matches!( + build_group_create("qa", "QA", None, &members, &[]), + Err(SdkError::InvalidInput(_)) + )); + + let channels = vec![uuid(); buzz_core::user_group::MAX_USER_GROUP_DEFAULT_CHANNELS + 1]; + assert!(matches!( + build_group_create("qa", "QA", None, &[], &channels), + Err(SdkError::InvalidInput(_)) + )); + } + + #[test] + fn group_edit_emits_updates_and_default_channel_replacement() { + let group_id = uuid(); + let channels = [uuid(), uuid()]; + let ev = sign( + build_group_edit( + group_id, + Some("mobile"), + Some("Mobile"), + Some("Updated description"), + Some(&channels), + ) + .unwrap(), + ); + + assert_eq!(ev.kind.as_u16(), KIND_GROUP_EDIT as u16); + assert_eq!(tag_values(&ev, "g"), [group_id.to_string()]); + assert_eq!(tag_values(&ev, "handle"), ["mobile"]); + assert_eq!(tag_values(&ev, "name"), ["Mobile"]); + assert_eq!(tag_values(&ev, "description"), ["Updated description"]); + assert_eq!( + tag_values(&ev, "channel"), + channels.map(|channel_id| channel_id.to_string()) + ); + } + + #[test] + fn group_edit_uses_empty_channel_sentinel_and_rejects_noop() { + let group_id = uuid(); + let ev = sign(build_group_edit(group_id, None, None, None, Some(&[])).unwrap()); + assert_eq!(tag_values(&ev, "channel"), [""]); + + assert!(matches!( + build_group_edit(group_id, None, None, None, None), + Err(SdkError::InvalidTag(_)) + )); + } + + #[test] + fn group_delete_emits_only_group_reference() { + let group_id = uuid(); + let ev = sign(build_group_delete(group_id).unwrap()); + + assert_eq!(ev.kind.as_u16(), KIND_GROUP_DELETE as u16); + assert!(ev.content.is_empty()); + assert_eq!(tag_values(&ev, "g"), [group_id.to_string()]); + assert_eq!(ev.tags.len(), 1); + } + + #[test] + fn group_membership_builders_emit_bulk_pubkey_tags() { + let group_id = uuid(); + let member_a = "A".repeat(64); + let member_b = "b".repeat(64); + let members = [member_a.as_str(), member_b.as_str()]; + + let add = sign(build_group_add_members(group_id, &members).unwrap()); + assert_eq!(add.kind.as_u16(), KIND_GROUP_ADD_MEMBER as u16); + assert_eq!(tag_values(&add, "g"), [group_id.to_string()]); + assert_eq!( + tag_values(&add, "p"), + [member_a.to_ascii_lowercase(), member_b.clone()] + ); + + let remove = sign(build_group_remove_members(group_id, &members).unwrap()); + assert_eq!(remove.kind.as_u16(), KIND_GROUP_REMOVE_MEMBER as u16); + assert_eq!(tag_values(&remove, "g"), [group_id.to_string()]); + assert_eq!( + tag_values(&remove, "p"), + [member_a.to_ascii_lowercase(), member_b] + ); + + assert!(matches!( + build_group_add_members(group_id, &[]), + Err(SdkError::InvalidTag(_)) + )); + assert!(matches!( + build_group_remove_members(group_id, &[]), + Err(SdkError::InvalidTag(_)) + )); + } + #[test] fn leave_happy_path() { let cid = uuid(); diff --git a/crates/buzz-test-client/tests/e2e_user_groups.rs b/crates/buzz-test-client/tests/e2e_user_groups.rs new file mode 100644 index 0000000000..2186fab441 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_user_groups.rs @@ -0,0 +1,571 @@ +//! End-to-end integration tests for relay-managed user groups. +//! +//! These tests require a running relay, Postgres, and Redis. They are ignored +//! by default so infrastructure-free test runs can still compile the suite. +//! +//! # Running +//! +//! ```text +//! cargo test -p buzz-test-client --test e2e_user_groups -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_sdk::{ + build_group_add_members, build_group_create, build_group_delete, build_group_edit, + build_group_remove_members, +}; +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; +use uuid::Uuid; + +const KIND_GROUP_CREATE: u16 = 47_000; +const KIND_GROUP_STATE: u16 = 39_100; +const KIND_CHANNEL_MEMBERS: u16 = 39_002; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn relay_host() -> String { + let relay = url::Url::parse(&relay_url()).expect("parse RELAY_URL"); + let host = relay.host_str().expect("RELAY_URL has a host"); + relay + .port() + .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")) +} + +fn sub_id(name: &str) -> String { + format!("e2e-user-groups-{name}-{}", Uuid::new_v4()) +} + +/// Unique group handle that fits the 32-char handle limit. +fn unique_handle(prefix: &str) -> String { + let hex = Uuid::new_v4().simple().to_string(); + format!("{prefix}-{}", &hex[..12]) +} + +async fn e2e_db_pool() -> sqlx::Pool { + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect to e2e Postgres") +} + +async fn ensure_test_community() -> Uuid { + let pool = e2e_db_pool().await; + let host = relay_host(); + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO communities (id, host) \ + VALUES ($1, $2) \ + ON CONFLICT (lower(host)) DO NOTHING", + ) + .bind(id) + .bind(&host) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("seed community {host}: {error}")); + + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(&host) + .fetch_one(&pool) + .await + .unwrap_or_else(|error| panic!("lookup community {host}: {error}")) +} + +async fn seed_community_member(keys: &Keys, role: &str) { + let pool = e2e_db_pool().await; + let community_id = ensure_test_community().await; + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE \ + SET role = $3, updated_at = now()", + ) + .bind(community_id) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("seed relay member {role}: {error}")); + + // Community admission on open relays (require_relay_membership=false, the + // dev default) checks the users table, so seed that row as well. + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(community_id) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(&pool) + .await + .unwrap_or_else(|error| panic!("seed user row {role}: {error}")); +} + +async fn create_channel(client: &mut BuzzTestClient, keys: &Keys, visibility: &str) -> Uuid { + let channel_id = Uuid::new_v4(); + let channel_id_text = channel_id.to_string(); + let channel_name = format!("user-groups-e2e-{}", channel_id.simple()); + let event = EventBuilder::new(Kind::Custom(9_007), "") + .tags([ + Tag::parse(["h", &channel_id_text]).expect("h tag"), + Tag::parse(["name", &channel_name]).expect("name tag"), + Tag::parse(["channel_type", "stream"]).expect("channel type tag"), + Tag::parse(["visibility", visibility]).expect("visibility tag"), + ]) + .sign_with_keys(keys) + .expect("sign create-channel event"); + + let ok = client + .send_event(event) + .await + .expect("submit create-channel event"); + assert!( + ok.accepted, + "channel creation should be accepted: {}", + ok.message + ); + channel_id +} + +fn tag_values(event: &Event, name: &str) -> Vec { + event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == name) + .filter_map(|tag| tag.content().map(ToOwned::to_owned)) + .collect() +} + +fn has_tag(event: &Event, name: &str) -> bool { + event.tags.iter().any(|tag| tag.kind().to_string() == name) +} + +fn group_id_from_create(event: &Event) -> Uuid { + let group_id = tag_values(event, "g") + .into_iter() + .next() + .expect("group create has a g tag"); + Uuid::parse_str(&group_id).expect("g tag contains a UUID") +} + +async fn query_snapshot(client: &mut BuzzTestClient, kind: u16, d_tag: &str) -> Event { + let sid = sub_id("snapshot"); + let filter = Filter::new() + .kind(Kind::Custom(kind)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe to snapshot"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect snapshot"); + assert_eq!( + events.len(), + 1, + "expected exactly one kind:{kind} snapshot for d={d_tag}" + ); + events.into_iter().next().expect("one snapshot") +} + +async fn channel_has_member(channel_id: Uuid, keys: &Keys) -> bool { + let pool = e2e_db_pool().await; + let community_id = ensure_test_community().await; + sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL\ + )", + ) + .bind(community_id) + .bind(channel_id) + .bind(keys.public_key().to_bytes().to_vec()) + .fetch_one(&pool) + .await + .expect("query active channel membership") +} + +#[tokio::test] +#[ignore] +async fn test_group_create_publishes_complete_snapshot() { + let creator = Keys::generate(); + let member = Keys::generate(); + seed_community_member(&creator, "member").await; + seed_community_member(&member, "member").await; + + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let channel_id = create_channel(&mut client, &creator, "open").await; + let member_hex = member.public_key().to_hex(); + let create = build_group_create( + &unique_handle("ios"), + "iOS Team", + Some("People who build the iOS app"), + &[&member_hex], + &[channel_id], + ) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + let group_id = group_id_from_create(&create); + let handle = tag_values(&create, "handle") + .into_iter() + .next() + .expect("handle tag"); + + let ok = client.send_event(create).await.expect("send group create"); + assert!( + ok.accepted, + "group creation should be accepted: {}", + ok.message + ); + + let snapshot = query_snapshot(&mut client, KIND_GROUP_STATE, &group_id.to_string()).await; + assert_eq!(snapshot.kind, Kind::Custom(KIND_GROUP_STATE)); + assert_eq!(tag_values(&snapshot, "d"), vec![group_id.to_string()]); + assert_eq!(tag_values(&snapshot, "handle"), vec![handle]); + assert_eq!(tag_values(&snapshot, "name"), vec!["iOS Team"]); + assert_eq!( + tag_values(&snapshot, "creator"), + vec![creator.public_key().to_hex()] + ); + assert_eq!(tag_values(&snapshot, "p"), vec![member_hex]); + assert_eq!( + tag_values(&snapshot, "channel"), + vec![channel_id.to_string()] + ); + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_duplicate_group_handle_is_rejected() { + let creator = Keys::generate(); + seed_community_member(&creator, "member").await; + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let handle = unique_handle("duplicate"); + + for expected_accepted in [true, false] { + let event = build_group_create(&handle, "Duplicate Handle", None, &[], &[]) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + let ok = client.send_event(event).await.expect("send group create"); + assert_eq!( + ok.accepted, expected_accepted, + "unexpected duplicate-handle result: {}", + ok.message + ); + if !expected_accepted { + assert!( + ok.message.contains("duplicate"), + "duplicate rejection should explain the conflict: {}", + ok.message + ); + } + } + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_invalid_group_handle_is_rejected() { + let creator = Keys::generate(); + seed_community_member(&creator, "member").await; + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let group_id = Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_GROUP_CREATE), "") + .tags([ + Tag::parse(["g", &group_id]).expect("g tag"), + Tag::parse(["handle", "Invalid Handle!"]).expect("handle tag"), + Tag::parse(["name", "Invalid"]).expect("name tag"), + ]) + .sign_with_keys(&creator) + .expect("sign invalid group create"); + + let ok = client + .send_event(event) + .await + .expect("send invalid group create"); + assert!(!ok.accepted, "invalid handle must be rejected"); + assert!( + ok.message.contains("group handle"), + "invalid-handle rejection should be specific: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_group_edit_requires_creator_or_community_admin() { + let creator = Keys::generate(); + let unrelated_member = Keys::generate(); + let admin = Keys::generate(); + seed_community_member(&creator, "member").await; + seed_community_member(&unrelated_member, "member").await; + seed_community_member(&admin, "admin").await; + + let create = build_group_create(&unique_handle("edit"), "Original Name", None, &[], &[]) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + let group_id = group_id_from_create(&create); + let mut creator_client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let ok = creator_client + .send_event(create) + .await + .expect("send group create"); + assert!(ok.accepted, "group create rejected: {}", ok.message); + creator_client + .disconnect() + .await + .expect("disconnect creator"); + + let mut member_client = BuzzTestClient::connect(&relay_url(), &unrelated_member) + .await + .expect("connect unrelated member"); + let unrelated_edit = build_group_edit(group_id, None, Some("Unauthorized Edit"), None, None) + .expect("build unrelated edit") + .sign_with_keys(&unrelated_member) + .expect("sign unrelated edit"); + let ok = member_client + .send_event(unrelated_edit) + .await + .expect("send unrelated edit"); + assert!(!ok.accepted, "unrelated member must not edit the group"); + assert!( + ok.message.contains("creator") && ok.message.contains("admin"), + "authorization rejection should explain who can edit: {}", + ok.message + ); + member_client + .disconnect() + .await + .expect("disconnect unrelated member"); + + let mut admin_client = BuzzTestClient::connect(&relay_url(), &admin) + .await + .expect("connect admin"); + let admin_edit = build_group_edit(group_id, None, Some("Admin Edited"), None, None) + .expect("build admin edit") + .sign_with_keys(&admin) + .expect("sign admin edit"); + let ok = admin_client + .send_event(admin_edit) + .await + .expect("send admin edit"); + assert!( + ok.accepted, + "community admin should be allowed to edit: {}", + ok.message + ); + let snapshot = query_snapshot(&mut admin_client, KIND_GROUP_STATE, &group_id.to_string()).await; + assert_eq!(tag_values(&snapshot, "name"), vec!["Admin Edited"]); + + admin_client.disconnect().await.expect("disconnect admin"); +} + +#[tokio::test] +#[ignore] +async fn test_group_membership_updates_snapshot_and_default_channel_membership() { + let creator = Keys::generate(); + let member = Keys::generate(); + seed_community_member(&creator, "member").await; + seed_community_member(&member, "member").await; + + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let channel_id = create_channel(&mut client, &creator, "open").await; + let channel_id_text = channel_id.to_string(); + let before_members = query_snapshot(&mut client, KIND_CHANNEL_MEMBERS, &channel_id_text).await; + + let create = build_group_create( + &unique_handle("autojoin"), + "Auto Join", + None, + &[], + &[channel_id], + ) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + let group_id = group_id_from_create(&create); + let ok = client.send_event(create).await.expect("send group create"); + assert!(ok.accepted, "group create rejected: {}", ok.message); + + let member_hex = member.public_key().to_hex(); + let add = build_group_add_members(group_id, &[&member_hex]) + .expect("build add-member") + .sign_with_keys(&creator) + .expect("sign add-member"); + let ok = client.send_event(add).await.expect("send add-member"); + assert!(ok.accepted, "add-member rejected: {}", ok.message); + + let group_after_add = + query_snapshot(&mut client, KIND_GROUP_STATE, &group_id.to_string()).await; + assert_eq!(tag_values(&group_after_add, "p"), vec![member_hex.clone()]); + assert!( + channel_has_member(channel_id, &member).await, + "added group member should be an active default-channel member" + ); + let after_members = query_snapshot(&mut client, KIND_CHANNEL_MEMBERS, &channel_id_text).await; + assert_ne!( + after_members.id, before_members.id, + "kind:39002 should be refreshed after the auto-join" + ); + assert!( + tag_values(&after_members, "p").contains(&member_hex), + "refreshed kind:39002 should contain the auto-joined member" + ); + + let remove = build_group_remove_members(group_id, &[&member.public_key().to_hex()]) + .expect("build remove-member") + .sign_with_keys(&creator) + .expect("sign remove-member"); + let ok = client.send_event(remove).await.expect("send remove-member"); + assert!(ok.accepted, "remove-member rejected: {}", ok.message); + let group_after_remove = + query_snapshot(&mut client, KIND_GROUP_STATE, &group_id.to_string()).await; + assert_ne!( + group_after_remove.id, group_after_add.id, + "remove-member should refresh the group snapshot" + ); + assert!( + tag_values(&group_after_remove, "p").is_empty(), + "removed member should be absent from the group snapshot" + ); + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_group_delete_publishes_tombstone_snapshot() { + let creator = Keys::generate(); + seed_community_member(&creator, "member").await; + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let create = build_group_create(&unique_handle("delete"), "Delete Me", None, &[], &[]) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + let group_id = group_id_from_create(&create); + let ok = client.send_event(create).await.expect("send group create"); + assert!(ok.accepted, "group create rejected: {}", ok.message); + + let delete = build_group_delete(group_id) + .expect("build group delete") + .sign_with_keys(&creator) + .expect("sign group delete"); + let ok = client.send_event(delete).await.expect("send group delete"); + assert!(ok.accepted, "group delete rejected: {}", ok.message); + + let tombstone = query_snapshot(&mut client, KIND_GROUP_STATE, &group_id.to_string()).await; + assert_eq!(tag_values(&tombstone, "d"), vec![group_id.to_string()]); + assert!( + has_tag(&tombstone, "deleted"), + "snapshot should be tombstoned" + ); + assert!( + tag_values(&tombstone, "handle").is_empty() + && tag_values(&tombstone, "name").is_empty() + && tag_values(&tombstone, "creator").is_empty() + && tag_values(&tombstone, "p").is_empty() + && tag_values(&tombstone, "channel").is_empty(), + "tombstone should not retain live group metadata" + ); + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_private_channel_is_rejected_as_group_default() { + let creator = Keys::generate(); + seed_community_member(&creator, "member").await; + let mut client = BuzzTestClient::connect(&relay_url(), &creator) + .await + .expect("connect creator"); + let private_channel = create_channel(&mut client, &creator, "private").await; + let create = build_group_create( + &unique_handle("private-default"), + "Private Default", + None, + &[], + &[private_channel], + ) + .expect("build group create") + .sign_with_keys(&creator) + .expect("sign group create"); + + let ok = client.send_event(create).await.expect("send group create"); + assert!( + !ok.accepted, + "private channel must not be accepted as a group default" + ); + assert!( + ok.message.contains("public") && ok.message.contains("active"), + "private-channel rejection should explain the constraint: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect creator"); +} + +#[tokio::test] +#[ignore] +async fn test_non_community_member_group_command_is_rejected() { + ensure_test_community().await; + let outsider = Keys::generate(); + let event = build_group_create(&unique_handle("outsider"), "Outsider Group", None, &[], &[]) + .expect("build outsider group create") + .sign_with_keys(&outsider) + .expect("sign outsider group create"); + + let response = reqwest::Client::new() + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", outsider.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).expect("serialize outsider command")) + .send() + .await + .expect("submit outsider command"); + + // The HTTP bridge maps relay-level rejections to 400 with an error body. + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "non-community-member group command must be rejected" + ); + let body: serde_json::Value = response.json().await.expect("parse rejection body"); + let error = body["error"].as_str().unwrap_or_default(); + assert!( + error.contains("only community members"), + "rejection should cite community membership: {body}" + ); +} diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2651318bc3..d1e964bc59 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -56,6 +56,12 @@ const overrides = new Map([ // queued for a broader composition-root split. Bumped for the // archive/unarchive/transfer community-management commands (web parity). ["src-tauri/src/lib.rs", 1013], + // user-groups client review fix: validated group-marker parsing and its + // tests were split into events/group_tags.rs. The remaining +13 lines are + // the group_tags parameters and append calls across the existing stream and + // forum builders; extracting those signatures would obscure the builders. + // (+1: clippy too_many_arguments allow on the build_message wrapper.) + ["src-tauri/src/events.rs", 1013], // persona-events rebase: build_deploy_payload threads `state` for the // read-time relay-URL workspace fallback while keeping the create-time env // pin (the credential-leak guard). Load-bearing feature growth from the @@ -521,7 +527,14 @@ const overrides = new Map([ // ownerPubkey) feeding the newly-added-mentions diff. Diff logic itself // lives in threading.ts (diffAddedMentionPubkeys); this is the minimal // composer-side wiring. Queued to split with the rest of this list. - ["src/features/messages/ui/MessageComposer.tsx", 1114], + // +1: user-groups client review fix disables group autocomplete while + // editing, avoiding an offered mention that the edit wire path cannot emit. + ["src/features/messages/ui/MessageComposer.tsx", 1115], + // +5 net: user-groups client review fixes thread the edit-mode group gate + // into collection candidates and suppress same-named groups when a person + // was explicitly selected. The group matching remains in + // useCollectionMentions.ts; these lines are the hook's existing ref wiring. + ["src/features/messages/lib/useMentions.ts", 1005], // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation // + globalProvider threading into getPersonaProviderOptions. All load-bearing diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33783c05a5..c21c523878 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -120,6 +120,9 @@ pub async fn sign_event( .collect::, _>>()?; let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); + if matches!(kind, 47000 | 47003 | 47004) { + builder = builder.allow_self_tagging(); + } if let Some(created_at) = created_at { builder = builder.custom_created_at(Timestamp::from(created_at)); } diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23..5cf747ef32 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -486,6 +486,7 @@ pub async fn send_channel_message( media_tags: Option>>, emoji_tags: Option>>, mention_tags: Option>>, + group_tags: Option>>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -497,6 +498,7 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let groups = group_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; @@ -508,6 +510,7 @@ pub async fn send_channel_message( &mention_refs, &media, &mention_refs_only, + &groups, )?, buzz_core_pkg::kind::KIND_FORUM_COMMENT => { let parent_id = parent_event_id @@ -522,6 +525,7 @@ pub async fn send_channel_message( &mention_refs, &media, &mention_refs_only, + &groups, )? } _ => { @@ -541,6 +545,7 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &groups, )? } }; @@ -707,6 +712,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + &[], client_tags, ) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..be8467c4c1 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -9,7 +9,10 @@ //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. +mod group_tags; + use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; +use group_tags::append_group_marker_tags; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; @@ -295,6 +298,7 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + group_tags: &[Vec], ) -> Result { build_message_with_client_tags( channel_id, @@ -312,6 +317,7 @@ pub fn build_message( media_tags, custom_emoji_tags, mention_ref_tags, + group_tags, &[], ) } @@ -330,6 +336,7 @@ pub fn build_message_with_client_tags( media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + group_tags: &[Vec], client_tags: &[Vec], ) -> Result { check_content(content)?; @@ -341,6 +348,7 @@ pub fn build_message_with_client_tags( imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + append_group_marker_tags(group_tags, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -369,12 +377,14 @@ pub fn build_forum_post( mentions: &[&str], media_tags: &[Vec], mention_ref_tags: &[Vec], + group_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; tags.extend(mention_tags(mentions)?); imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + append_group_marker_tags(group_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags)) } @@ -386,6 +396,7 @@ pub fn build_forum_comment( mentions: &[&str], media_tags: &[Vec], mention_ref_tags: &[Vec], + group_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; @@ -393,6 +404,7 @@ pub fn build_forum_comment( tags.extend(mention_tags(mentions)?); imeta_tags(media_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + append_group_marker_tags(group_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } @@ -856,6 +868,7 @@ mod tests { assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err()); assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err()); } + /// Builder layout regression for the NIP-IA owner-of-agent archive flow. /// Compares against `docs/nips/NIP-IA.md` §Vector 1. #[test] diff --git a/desktop/src-tauri/src/events/group_tags.rs b/desktop/src-tauri/src/events/group_tags.rs new file mode 100644 index 0000000000..3bf7ef6ee4 --- /dev/null +++ b/desktop/src-tauri/src/events/group_tags.rs @@ -0,0 +1,87 @@ +use nostr::Tag; +use uuid::Uuid; + +pub(super) fn append_group_marker_tags( + groups: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for group in groups { + if group.first().map(String::as_str) != Some("group") { + return Err(format!( + "group marker tags must use 'group' prefix (got {:?})", + group.first() + )); + } + if group.len() != 3 || group[1].is_empty() || group[2].is_empty() { + return Err("group marker tag must be [\"group\", id, handle]".into()); + } + Uuid::parse_str(&group[1]).map_err(|_| "group marker tag has invalid UUID")?; + let handle = group[2].as_bytes(); + if !(2..=32).contains(&handle.len()) + || !handle[0].is_ascii_lowercase() && !handle[0].is_ascii_digit() + || handle.iter().any(|byte| { + !byte.is_ascii_lowercase() + && !byte.is_ascii_digit() + && *byte != b'_' + && *byte != b'-' + }) + { + return Err("group marker tag has invalid handle".into()); + } + tags.push( + Tag::parse(["group", group[1].as_str(), group[2].as_str()]) + .map_err(|error| format!("invalid group marker tag: {error}"))?, + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::append_group_marker_tags; + + #[test] + fn validates_and_preserves_marker_shape() { + let mut tags = Vec::new(); + append_group_marker_tags( + &[vec![ + "group".into(), + "11111111-1111-4111-8111-111111111111".into(), + "ios-team".into(), + ]], + &mut tags, + ) + .unwrap(); + let marker = tags[0].as_slice(); + assert_eq!(marker[0], "group"); + assert_eq!(marker[1], "11111111-1111-4111-8111-111111111111"); + assert_eq!(marker[2], "ios-team"); + } + + #[test] + fn rejects_wrong_prefix_and_malformed_shape() { + assert!(append_group_marker_tags( + &[vec![ + "p".into(), + "11111111-1111-4111-8111-111111111111".into(), + "ios-team".into(), + ]], + &mut Vec::new(), + ) + .is_err()); + assert!(append_group_marker_tags( + &[vec!["group".into(), "not-a-uuid".into(), "ios-team".into()]], + &mut Vec::new(), + ) + .is_err()); + assert!(append_group_marker_tags( + &[vec![ + "group".into(), + "11111111-1111-4111-8111-111111111111".into(), + "Invalid Handle".into(), + ]], + &mut Vec::new(), + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6..6f754ae1ee 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -298,7 +298,7 @@ pub(crate) fn spawn_transcription_task( let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { + match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[], &[]) { Ok(b) => b, Err(e) => { eprintln!("buzz-desktop: STT build_message: {e}"); diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index 73505d1798..37593c2689 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -1,7 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts"; +import { + deriveShellRoute, + shouldBounceForChannelNotification, +} from "./AppShell.helpers.ts"; + +test("deriveShellRoute selects the groups view", () => { + assert.deepEqual(deriveShellRoute("/groups"), { + selectedChannelId: null, + selectedView: "groups", + }); +}); test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..1eaa77a2ca 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -7,6 +7,7 @@ export type AppView = | "channel" | "messages" | "agents" + | "groups" | "workflows" | "pulse" | "projects"; @@ -132,6 +133,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/groups") { + return { + selectedChannelId: null, + selectedView: "groups", + }; + } + if (pathname === "/workflows" || pathname.startsWith("/workflows/")) { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..72b8f060f5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -56,6 +56,7 @@ import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; +import { useGroupsLiveUpdates } from "@/features/groups/groupHooks"; import { useProfileQuery } from "@/features/profile/hooks"; import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController"; import { @@ -125,6 +126,7 @@ export function AppShell() { const { goAgents, goChannel, + goGroups, goHome, goNewMessage, goProjects, @@ -197,6 +199,7 @@ export function AppShell() { usePresenceSubscription(); useUserStatusSubscription(); useCommunityEmojiLiveUpdates(); + useGroupsLiveUpdates(); useMembershipNotifications(identityQuery.data?.pubkey); const presenceSession = usePresenceSession(deferredPubkey); const selfStatusQuery = useUserStatusQuery( @@ -872,6 +875,7 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectGroups={() => void goGroups()} onSelectChannel={(channelId) => void goChannel(channelId) } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..24b4e741bf 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -68,6 +68,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goGroups = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/groups", + }, + behavior, + ), + [commitNavigation], + ); + const goPulse = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -298,6 +309,7 @@ export function useAppNavigation() { goAgents, goChannel, goForumPost, + goGroups, goHome, goNewMessage, goProject, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..2cb8825d96 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; import { Route as agentsRouteImport } from "./routes/agents"; +import { Route as groupsRouteImport } from "./routes/groups"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; @@ -48,6 +49,11 @@ const agentsRoute = agentsRouteImport.update({ path: "/agents", getParentRoute: () => rootRouteImport, } as any); +const groupsRoute = groupsRouteImport.update({ + id: "/groups", + path: "/groups", + getParentRoute: () => rootRouteImport, +} as any); const indexRoute = indexRouteImport.update({ id: "/", path: "/", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/groups": typeof groupsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/groups": typeof groupsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/groups": typeof groupsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/groups" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/groups" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/groups" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + groupsRoute: typeof groupsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -227,6 +240,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof agentsRouteImport; parentRoute: typeof rootRouteImport; }; + "/groups": { + id: "/groups"; + path: "/groups"; + fullPath: "/groups"; + preLoaderRoute: typeof groupsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/": { id: "/"; path: "/"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + groupsRoute: groupsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..e10374aa8e 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -3,6 +3,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), + route("/groups", "groups.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), diff --git a/desktop/src/app/routes/groups.tsx b/desktop/src/app/routes/groups.tsx new file mode 100644 index 0000000000..08675a941c --- /dev/null +++ b/desktop/src/app/routes/groups.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const GroupsScreen = React.lazy(async () => { + const module = await import("@/features/groups/ui/GroupsScreen"); + return { default: module.GroupsScreen }; +}); + +export const Route = createFileRoute("/groups")({ + component: GroupsRouteComponent, +}); + +function GroupsRouteComponent() { + usePreviewFeatureWarning("groups"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 63ab164ea4..1f756995e7 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -5,6 +5,8 @@ import { truncatePubkey } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; +import { useGroupsQuery } from "@/features/groups/groupHooks"; +import type { UserGroup } from "@/shared/api/relayGroups"; import type { AddChannelMembersResult, ChannelMember, @@ -44,6 +46,7 @@ export function ChannelMemberInviteCard({ const [selectedInvitees, setSelectedInvitees] = React.useState< UserSearchResult[] >([]); + const [selectedGroups, setSelectedGroups] = React.useState([]); const [inviteRole, setInviteRole] = React.useState>("member"); const [submissionErrors, setSubmissionErrors] = React.useState< @@ -82,6 +85,16 @@ export function ChannelMemberInviteCard({ () => new Set(existingMembers.map((member) => member.pubkey.toLowerCase())), [existingMembers], ); + const selectedGroupPubkeys = React.useMemo( + () => + new Set( + selectedGroups + .flatMap((group) => group.memberPubkeys) + .map((pubkey) => pubkey.toLowerCase()), + ), + [selectedGroups], + ); + const groupsQuery = useGroupsQuery(); const userSearchQuery = useUserSearchQuery(deferredInviteQuery, { enabled: open && deferredInviteQuery.length > 0, // Ask for more than we'll display so server-side ranking has room to be @@ -95,11 +108,13 @@ export function ChannelMemberInviteCard({ (user) => !memberPubkeys.has(user.pubkey.toLowerCase()) && !selectedInviteePubkeys.has(user.pubkey.toLowerCase()) && + !selectedGroupPubkeys.has(user.pubkey.toLowerCase()) && !isArchivedDiscovery(user.pubkey), ), [ isArchivedDiscovery, memberPubkeys, + selectedGroupPubkeys, selectedInviteePubkeys, userSearchQuery.data, ], @@ -109,11 +124,42 @@ export function ChannelMemberInviteCard({ if (!open) { setInviteQuery(""); setSelectedInvitees([]); + setSelectedGroups([]); setSubmissionErrors([]); } }, [open]); - const inviteTargets = selectedInvitees.map((invitee) => invitee.pubkey); + const matchingGroups = React.useMemo(() => { + const query = deferredInviteQuery.toLowerCase(); + const selectedIds = new Set(selectedGroups.map((group) => group.id)); + return (groupsQuery.data ?? []).filter((group) => { + if (selectedIds.has(group.id)) return false; + if ( + query && + !group.name.toLowerCase().includes(query) && + !group.handle.toLowerCase().includes(query) + ) { + return false; + } + return group.memberPubkeys.some( + (pubkey) => !memberPubkeys.has(pubkey.toLowerCase()), + ); + }); + }, [deferredInviteQuery, groupsQuery.data, memberPubkeys, selectedGroups]); + + const inviteTargets = React.useMemo( + () => [ + ...new Set( + [ + ...selectedInvitees.map((invitee) => invitee.pubkey), + ...selectedGroups.flatMap((group) => group.memberPubkeys), + ] + .map((pubkey) => pubkey.toLowerCase()) + .filter((pubkey) => !memberPubkeys.has(pubkey)), + ), + ], + [memberPubkeys, selectedGroups, selectedInvitees], + ); return (
!addedPubkeys.has(invitee.pubkey.toLowerCase()), ), ); + setSelectedGroups((current) => + current.filter((group) => + group.memberPubkeys.some((pubkey) => { + const normalized = pubkey.toLowerCase(); + return ( + !memberPubkeys.has(normalized) && + !addedPubkeys.has(normalized) + ); + }), + ), + ); setInviteQuery(""); setSubmissionErrors(result.errors); }); @@ -199,6 +256,39 @@ export function ChannelMemberInviteCard({ ))} ) : null} + {selectedGroups.length > 0 ? ( +
+ {selectedGroups.map((group) => { + const count = group.memberPubkeys.filter( + (pubkey) => !memberPubkeys.has(pubkey.toLowerCase()), + ).length; + return ( +
+ + @{group.handle} ({count}) + + +
+ ); + })} +
+ ) : null} {selectedInvitees.length > 0 ? (
{selectedInvitees.map((invitee) => ( @@ -260,6 +350,45 @@ export function ChannelMemberInviteCard({ )}
) : null} + {matchingGroups.length > 0 ? ( +
+

+ Groups +

+
+ {matchingGroups.map((group) => { + const count = group.memberPubkeys.filter( + (pubkey) => !memberPubkeys.has(pubkey.toLowerCase()), + ).length; + return ( + + ); + })} +
+
+ ) : null} {userSearchQuery.error instanceof Error ? (

diff --git a/desktop/src/features/forum/hooks.ts b/desktop/src/features/forum/hooks.ts index f4537e9290..774cd4c766 100644 --- a/desktop/src/features/forum/hooks.ts +++ b/desktop/src/features/forum/hooks.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getForumPosts, getForumThread } from "@/shared/api/forum"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; +import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, @@ -75,13 +76,17 @@ export function useCreateForumPostMutation(channel: Channel | null) { throw new Error("No channel selected."); } + const { mediaTags: imetaTags, groupTags } = splitOutgoingTags(mediaTags); return sendChannelMessage( channel.id, content, null, - mediaTags, + imetaTags, mentionPubkeys, KIND_FORUM_POST, + undefined, + undefined, + groupTags, ); }, onSuccess: () => { @@ -161,13 +166,17 @@ export function useCreateForumReplyMutation(channel: Channel | null) { throw new Error("No channel selected."); } + const { mediaTags: imetaTags, groupTags } = splitOutgoingTags(mediaTags); return sendChannelMessage( channel.id, content, parentEventId, - mediaTags, + imetaTags, mentionPubkeys, KIND_FORUM_COMMENT, + undefined, + undefined, + groupTags, ); }, onSuccess: (_data, variables) => { diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 79dab559c4..34916bb5d8 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -2,7 +2,12 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; import { ChevronDown } from "lucide-react"; -import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; +import { toast } from "sonner"; +import { expandGroupMentions } from "@/features/messages/lib/groupMentionExpansion"; +import { + buildOutgoingMessage, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -225,7 +230,18 @@ export function ForumComposer({ return; } - const pubkeys = mentions.extractMentionPubkeys(trimmed); + const groups = mentions.extractMentionGroups(trimmed); + if (groups.length > 0 && !mentions.hasResolvedMembers) { + toast.error( + "Checking channel members. Try sending the group mention again in a moment.", + ); + return; + } + const groupExpansion = expandGroupMentions({ + channelMemberPubkeys: mentions.memberPubkeys, + groups, + individualMentionPubkeys: mentions.extractMentionPubkeys(trimmed), + }); // Reuse the shared send-path builder so forum/notes posts emit the same // body + imeta as chat: generic files become `[filename](url)` links with a @@ -235,6 +251,10 @@ export function ForumComposer({ trimmed, currentPendingImeta, ); + const outgoingTags = mergeOutgoingTags( + mediaTags, + groupExpansion.markerTags, + ); // Save draft state so we can restore on failure. const savedContent = contentRef.current; @@ -248,7 +268,11 @@ export function ForumComposer({ channelLinks.clearChannels(); setIsEmojiPickerOpen(false); - const result = submitter(finalContent, pubkeys, mediaTags); + const result = submitter( + finalContent, + groupExpansion.mentionPubkeys, + outgoingTags, + ); const completeSubmission = () => { setSubmitMode("primary"); if (compact) setIsCompactExpanded(false); @@ -271,7 +295,10 @@ export function ForumComposer({ compact, media.pendingImetaRef, media.setPendingImeta, + mentions.extractMentionGroups, mentions.extractMentionPubkeys, + mentions.hasResolvedMembers, + mentions.memberPubkeys, mentions.clearMentions, channelLinks.clearChannels, richText.clearContent, diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 311df0f97f..6180f51ffd 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -10,7 +10,10 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { parseImetaTags } from "@/features/messages/lib/parseImeta"; -import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; +import { + resolveGroupMentionHandles, + resolveMentionProps, +} from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import { formatRelativeTime } from "../lib/time"; @@ -121,6 +124,7 @@ export function ForumPostCard({ { + let active = true; + let unsubscribe: (() => Promise) | undefined; + + void subscribeToUserGroups((event) => { + if (!userGroupIdFromSnapshot(event)) return; + void queryClient.invalidateQueries({ queryKey: groupsQueryKey }); + }) + .then((stop) => { + if (active) { + unsubscribe = stop; + } else { + void stop(); + } + }) + .catch(() => { + void queryClient.invalidateQueries({ queryKey: groupsQueryKey }); + }); + + return () => { + active = false; + void unsubscribe?.(); + }; + }, [queryClient]); +} + +export function useCreateGroupMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateUserGroupInput) => createUserGroup(input), + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: groupsQueryKey }); + const previous = queryClient.getQueryData(groupsQueryKey); + queryClient.setQueryData(groupsQueryKey, (current = []) => [ + input, + ...current.filter((group) => group.id !== input.id), + ]); + return { previous }; + }, + onError: (_error, _input, context) => { + queryClient.setQueryData(groupsQueryKey, context?.previous); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: groupsQueryKey }); + }, + }); +} + +export function useUpdateGroupMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdateUserGroupInput) => updateUserGroup(input), + onMutate: async ({ next }) => { + await queryClient.cancelQueries({ queryKey: groupsQueryKey }); + const previous = queryClient.getQueryData(groupsQueryKey); + queryClient.setQueryData(groupsQueryKey, (current = []) => + current.map((group) => (group.id === next.id ? next : group)), + ); + return { previous }; + }, + onError: (_error, _input, context) => { + queryClient.setQueryData(groupsQueryKey, context?.previous); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: groupsQueryKey }); + }, + }); +} + +export function useDeleteGroupMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteUserGroup, + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: groupsQueryKey }); + const previous = queryClient.getQueryData(groupsQueryKey); + queryClient.setQueryData(groupsQueryKey, (current = []) => + current.filter((group) => group.id !== id), + ); + return { previous }; + }, + onError: (_error, _id, context) => { + queryClient.setQueryData(groupsQueryKey, context?.previous); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: groupsQueryKey }); + }, + }); +} diff --git a/desktop/src/features/groups/groupValidation.test.mjs b/desktop/src/features/groups/groupValidation.test.mjs new file mode 100644 index 0000000000..dffdc4e135 --- /dev/null +++ b/desktop/src/features/groups/groupValidation.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isValidGroupHandle } from "./groupValidation.ts"; + +test("group handle validation mirrors the relay pattern", () => { + for (const handle of ["ab", "ios-team", "risk_models", "a1"]) { + assert.equal(isValidGroupHandle(handle), true, handle); + } + for (const handle of ["a", "IOS", "-team", "team space", "a".repeat(33)]) { + assert.equal(isValidGroupHandle(handle), false, handle); + } +}); diff --git a/desktop/src/features/groups/groupValidation.ts b/desktop/src/features/groups/groupValidation.ts new file mode 100644 index 0000000000..4e4766199e --- /dev/null +++ b/desktop/src/features/groups/groupValidation.ts @@ -0,0 +1,5 @@ +export const GROUP_HANDLE_PATTERN = /^[a-z0-9][a-z0-9_-]{1,31}$/; + +export function isValidGroupHandle(handle: string): boolean { + return GROUP_HANDLE_PATTERN.test(handle); +} diff --git a/desktop/src/features/groups/ui/GroupDialog.tsx b/desktop/src/features/groups/ui/GroupDialog.tsx new file mode 100644 index 0000000000..91b21451d3 --- /dev/null +++ b/desktop/src/features/groups/ui/GroupDialog.tsx @@ -0,0 +1,452 @@ +import { Search, X } from "lucide-react"; +import * as React from "react"; + +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { isValidGroupHandle } from "@/features/groups/groupValidation"; +import type { UserGroup } from "@/shared/api/relayGroups"; +import type { Channel, UserSearchResult } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type GroupDialogProps = { + channels: Channel[]; + error: Error | null; + group: UserGroup | null; + isPending: boolean; + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (group: UserGroup) => Promise; + viewerPubkey: string; +}; + +type SelectedMember = { + avatarUrl: string | null; + displayName: string; + isAgent: boolean; + pubkey: string; +}; + +function userLabel(user: UserSearchResult): string { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +export function GroupDialog({ + channels, + error, + group, + isPending, + mode, + open, + onOpenChange, + onSubmit, + viewerPubkey, +}: GroupDialogProps) { + const [name, setName] = React.useState(""); + const [handle, setHandle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [memberQuery, setMemberQuery] = React.useState(""); + const [selectedMemberPubkeys, setSelectedMemberPubkeys] = React.useState< + string[] + >([]); + const [selectedMembersByPubkey, setSelectedMembersByPubkey] = React.useState< + Map + >(new Map()); + const [defaultChannelIds, setDefaultChannelIds] = React.useState( + [], + ); + const deferredMemberQuery = React.useDeferredValue(memberQuery.trim()); + const initialProfilesQuery = useUsersBatchQuery(selectedMemberPubkeys, { + enabled: open && selectedMemberPubkeys.length > 0, + }); + const userSearchQuery = useUserSearchQuery(deferredMemberQuery, { + enabled: open && deferredMemberQuery.length > 0, + limit: 25, + }); + + React.useEffect(() => { + if (!open) return; + setName(group?.name ?? ""); + setHandle(group?.handle ?? ""); + setDescription(group?.description ?? ""); + setSelectedMemberPubkeys(group?.memberPubkeys ?? []); + setSelectedMembersByPubkey(new Map()); + setDefaultChannelIds(group?.defaultChannelIds ?? []); + setMemberQuery(""); + }, [group, open]); + + React.useEffect(() => { + const profiles = initialProfilesQuery.data?.profiles; + if (!profiles) return; + setSelectedMembersByPubkey((current) => { + const next = new Map(current); + for (const pubkey of selectedMemberPubkeys) { + const normalized = pubkey.toLowerCase(); + const profile = profiles[normalized]; + if (!profile || next.has(normalized)) continue; + next.set(normalized, { + avatarUrl: profile.avatarUrl, + displayName: + profile.displayName?.trim() || + profile.nip05Handle?.trim() || + truncatePubkey(pubkey), + isAgent: profile.isAgent === true, + pubkey: normalized, + }); + } + return next; + }); + }, [initialProfilesQuery.data?.profiles, selectedMemberPubkeys]); + + const publicChannels = React.useMemo( + () => + channels + .filter( + (channel) => + channel.visibility === "open" && + channel.channelType !== "dm" && + channel.archivedAt === null, + ) + .sort((left, right) => left.name.localeCompare(right.name)), + [channels], + ); + const selectedSet = React.useMemo( + () => new Set(selectedMemberPubkeys.map((pubkey) => pubkey.toLowerCase())), + [selectedMemberPubkeys], + ); + const memberResults = React.useMemo( + () => + (userSearchQuery.data ?? []).filter( + (user) => !selectedSet.has(user.pubkey.toLowerCase()), + ), + [selectedSet, userSearchQuery.data], + ); + const normalizedHandle = handle.trim().toLowerCase(); + const showHandleError = + normalizedHandle.length > 0 && !isValidGroupHandle(normalizedHandle); + const canSubmit = + name.trim().length > 0 && + isValidGroupHandle(normalizedHandle) && + !isPending; + + function addMember(user: UserSearchResult) { + const pubkey = user.pubkey.toLowerCase(); + setSelectedMemberPubkeys((current) => [...current, pubkey]); + setSelectedMembersByPubkey((current) => { + const next = new Map(current); + next.set(pubkey, { + avatarUrl: user.avatarUrl, + displayName: userLabel(user), + isAgent: user.isAgent, + pubkey, + }); + return next; + }); + setMemberQuery(""); + } + + function removeMember(pubkey: string) { + setSelectedMemberPubkeys((current) => + current.filter((candidate) => candidate !== pubkey), + ); + setSelectedMembersByPubkey((current) => { + const next = new Map(current); + next.delete(pubkey); + return next; + }); + } + + function toggleChannel(channelId: string) { + setDefaultChannelIds((current) => + current.includes(channelId) + ? current.filter((id) => id !== channelId) + : [...current, channelId], + ); + } + + return ( +

+ +
+ + + {mode === "create" ? "New group" : "Edit group"} + + + Mention a shared set of people and agents with one handle. + + + +
+
+ + setName(event.target.value)} + placeholder="iOS team" + value={name} + /> +
+ +
+ +
+ @ + + setHandle( + event.target.value.replace(/^@/, "").toLowerCase(), + ) + } + placeholder="ios-team" + value={handle} + /> +
+ {showHandleError ? ( +

+ Use 2–32 lowercase letters, numbers, underscores, or hyphens. +

+ ) : null} +
+ +
+ +