diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65c9dd6203..b5728581b9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -66,6 +66,109 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// Build a kind:10100 agent profile event for persistent directory discovery. +fn build_agent_profile_event( + keys: &nostr::Keys, + agent_name: &str, + channel_ids: &HashSet, + respond_to: &RespondTo, + respond_to_allowlist: &HashSet, +) -> Result { + use buzz_core::kind::KIND_AGENT_PROFILE; + use nostr::{EventBuilder, Kind}; + + let mut channels: Vec = channel_ids.iter().map(Uuid::to_string).collect(); + channels.sort_unstable(); + let mut allowlist: Vec = if respond_to == &RespondTo::Allowlist { + respond_to_allowlist.iter().cloned().collect() + } else { + Vec::new() + }; + allowlist.sort_unstable(); + let content = serde_json::json!({ + "name": agent_name, + "display_name": agent_name, + "agent_type": "agent", + "channels": channels.clone(), + "channel_ids": channels, + "capabilities": [], + "status": "online", + "respond_to": respond_to.to_string(), + "respond_to_allowlist": allowlist, + }); + let content = serde_json::to_string(&content) + .map_err(|e| relay::RelayError::Http(format!("agent profile serialize error: {e}")))?; + + EventBuilder::new(Kind::Custom(KIND_AGENT_PROFILE as u16), content) + .tags([]) + .sign_with_keys(keys) + .map_err(|e| relay::RelayError::Http(format!("agent profile sign error: {e}"))) +} + +/// Publish a kind:10100 agent profile through the authenticated HTTP bridge. +/// +/// Agent profiles are persistent replaceable events, so unlike presence they +/// can use `POST /events` and remain available while the agent is offline. +async fn publish_agent_profile( + rest_client: &relay::RestClient, + keys: &nostr::Keys, + agent_name: &str, + channel_ids: &HashSet, + respond_to: &RespondTo, + respond_to_allowlist: &HashSet, +) -> Result<(), relay::RelayError> { + let event = build_agent_profile_event( + keys, + agent_name, + channel_ids, + respond_to, + respond_to_allowlist, + )?; + rest_client.submit_event(&event).await?; + Ok(()) +} + +/// Extract the preferred agent display name from a kind:0 query response. +fn agent_profile_name_from_kind0_response( + response: &serde_json::Value, +) -> Result, relay::RelayError> { + let events = response.as_array().ok_or_else(|| { + relay::RelayError::Http("agent kind:0 query returned a non-array response".into()) + })?; + let Some(event) = events.first() else { + return Ok(None); + }; + let content = event + .get("content") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + relay::RelayError::Http("agent kind:0 profile has no content string".into()) + })?; + let profile: serde_json::Value = serde_json::from_str(content) + .map_err(|e| relay::RelayError::Http(format!("agent kind:0 profile parse error: {e}")))?; + + Ok(["display_name", "name"].into_iter().find_map(|field| { + profile + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + })) +} + +/// Resolve the agent's configured display name from its own kind:0 profile. +async fn resolve_agent_profile_name( + rest_client: &relay::RestClient, + public_key: nostr::PublicKey, +) -> Result, relay::RelayError> { + let filter = nostr::Filter::new() + .kind(nostr::Kind::Metadata) + .author(public_key) + .limit(1); + let response = rest_client.query(&[filter]).await?; + agent_profile_name_from_kind0_response(&response) +} + /// Publish a kind:20001 presence update event via the WebSocket connection. /// /// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence @@ -1645,6 +1748,27 @@ async fn tokio_main() -> Result<()> { let presence_publisher = relay.event_publisher(); let presence_keys = config.keys.clone(); + let agent_profile_rest_client = relay.rest_client(); + let agent_profile_name = match resolve_agent_profile_name( + &agent_profile_rest_client, + presence_keys.public_key(), + ) + .await + { + Ok(Some(name)) => name, + Ok(None) => { + tracing::warn!( + "agent kind:0 profile has no usable display name; falling back to runtime identity" + ); + config::normalize_agent_command_identity(&config.agent_command) + } + Err(e) => { + tracing::warn!( + "failed to resolve agent name from kind:0 profile; falling back to runtime identity: {e}" + ); + config::normalize_agent_command_identity(&config.agent_command) + } + }; // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. let startup_owner: Option = resolve_agent_owner(&config); @@ -1716,7 +1840,8 @@ async fn tokio_main() -> Result<()> { .map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?; tracing::info!("discovered {} channel(s)", channel_info_map.len()); - let channel_ids: Vec = channel_info_map.keys().copied().collect(); + let mut discovered_channel_ids: HashSet = channel_info_map.keys().copied().collect(); + let channel_ids: Vec = discovered_channel_ids.iter().copied().collect(); let rules: Vec = match config.subscribe_mode { SubscribeMode::Mentions => { @@ -1791,6 +1916,19 @@ async fn tokio_main() -> Result<()> { // connected. Publishing after channel subscriptions gives desktop callers // a durable readiness boundary before they send a startup mention. if config.presence_enabled { + match publish_agent_profile( + &agent_profile_rest_client, + &presence_keys, + &agent_profile_name, + &discovered_channel_ids, + &config.respond_to, + &config.respond_to_allowlist, + ) + .await + { + Ok(_) => tracing::info!("agent profile published"), + Err(e) => tracing::warn!("failed to publish initial agent profile: {e}"), + } match publish_presence(&presence_publisher, &presence_keys, "online").await { Ok(_) => tracing::info!("presence set to online"), Err(e) => tracing::warn!("failed to set initial presence: {e}"), @@ -2262,6 +2400,34 @@ async fn tokio_main() -> Result<()> { } membership_newest_ts.insert(ch, ts); + let profile_channels_changed = + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION { + discovered_channel_ids.insert(ch) + } else { + discovered_channel_ids.remove(&ch) + }; + if profile_channels_changed && config.presence_enabled { + match publish_agent_profile( + &agent_profile_rest_client, + &presence_keys, + &agent_profile_name, + &discovered_channel_ids, + &config.respond_to, + &config.respond_to_allowlist, + ) + .await + { + Ok(_) => tracing::info!( + channel_id = %ch, + "agent profile republished after membership change" + ), + Err(e) => tracing::warn!( + channel_id = %ch, + "failed to republish agent profile after membership change: {e}" + ), + } + } + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION { // Clear removal tracking so sessions are not // stripped for a legitimately re-added channel. @@ -4748,6 +4914,87 @@ mod owner_cache_tests { } } +#[cfg(test)] +mod agent_profile_tests { + use super::*; + + #[test] + fn agent_profile_event_contains_mobile_directory_fields() { + let keys = nostr::Keys::generate(); + let first = + Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid test UUID"); + let second = + Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid test UUID"); + let channel_ids = HashSet::from([second, first]); + let first_allowlist_key = "aa".repeat(32); + let second_allowlist_key = "bb".repeat(32); + let respond_to_allowlist = + HashSet::from([second_allowlist_key.clone(), first_allowlist_key.clone()]); + let resolved_name = agent_profile_name_from_kind0_response(&serde_json::json!([ + { + "content": r#"{"display_name":"Research Agent","name":"fallback-name"}"#, + } + ])) + .expect("profile response parses") + .expect("profile contains a display name"); + + let event = build_agent_profile_event( + &keys, + &resolved_name, + &channel_ids, + &RespondTo::Allowlist, + &respond_to_allowlist, + ) + .expect("profile event signs"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("profile content is JSON"); + + assert_eq!( + event.kind.as_u16(), + buzz_core::kind::KIND_AGENT_PROFILE as u16 + ); + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!( + content, + serde_json::json!({ + "name": "Research Agent", + "display_name": "Research Agent", + "agent_type": "agent", + "channels": [first.to_string(), second.to_string()], + "channel_ids": [first.to_string(), second.to_string()], + "capabilities": [], + "status": "online", + "respond_to": "allowlist", + "respond_to_allowlist": [first_allowlist_key, second_allowlist_key], + }) + ); + } + + #[test] + fn agent_profile_event_falls_back_to_runtime_identity_without_kind0_profile() { + let keys = nostr::Keys::generate(); + let fallback = config::normalize_agent_command_identity("/usr/local/bin/codex-acp"); + let resolved_name = agent_profile_name_from_kind0_response(&serde_json::json!([])) + .expect("empty profile response is valid") + .unwrap_or(fallback); + + let event = build_agent_profile_event( + &keys, + &resolved_name, + &HashSet::new(), + &RespondTo::Anyone, + &HashSet::new(), + ) + .expect("profile event signs"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("profile content is JSON"); + + assert_eq!(content["name"], "codex-acp"); + assert_eq!(content["display_name"], "codex-acp"); + assert_eq!(content["respond_to_allowlist"], serde_json::json!([])); + } +} + #[cfg(test)] mod author_gate_tests { use super::*; diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..6c8bbb5e3a 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1001,7 +1001,7 @@ pub async fn cmd_remove_channel_member( Ok(()) } -/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event. +/// Set the channel addition policy — sign and submit a kind:10101 event. pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> { match policy { "anyone" | "owner_only" | "nobody" => {} @@ -1014,7 +1014,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), // Check if this policy is allowed by the deployment. // NOTE: This gate covers only the `buzz channels set-add-policy` CLI path. - // A client that submits a kind:10100 event directly to the relay bypasses + // A client that submits a kind:10101 event directly to the relay bypasses // this check. Full enforcement requires relay-side validation, which is // intentionally out of scope for this change (see team decision: no // relay-side enforcement of client behavior). @@ -1035,7 +1035,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), let content = serde_json::json!({ "channel_add_policy": policy }).to_string(); use nostr::{EventBuilder, Kind}; let builder = EventBuilder::new( - Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16), + Kind::Custom(buzz_sdk::kind::KIND_CHANNEL_ADD_POLICY as u16), &content, ) .tags([]); diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..93a9551113 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -85,6 +85,11 @@ pub const KIND_HTTP_AUTH: u32 = 27235; // NEW: Buzz command kinds (Pure Nostr plan) /// Agent metadata + owner reference (replaceable, agent-authored). pub const KIND_AGENT_PROFILE: u32 = 10100; +/// Per-user channel-add policy (replaceable, user-authored). +/// +/// Content carries the user's `channel_add_policy` preference. The relay +/// projects the effective policy into the users table for enforcement. +pub const KIND_CHANNEL_ADD_POLICY: u32 = 10101; /// NIP-AE: Agent Engram (parameterized replaceable, agent-authored). /// @@ -650,6 +655,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIFT_WRAP, KIND_FILE_METADATA, KIND_AGENT_PROFILE, + KIND_CHANNEL_ADD_POLICY, KIND_AGENT_ENGRAM, KIND_EVENT_REMINDER, KIND_PERSONA, @@ -853,6 +859,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { // Compile-time: new kinds are in the expected ranges. const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999 +const _: () = assert!(is_replaceable(KIND_CHANNEL_ADD_POLICY)); // 10101 ∈ 10000–19999 const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 @@ -907,6 +914,14 @@ mod tests { } } + #[test] + fn channel_add_policy_kind_is_registered_and_replaceable() { + assert_eq!(KIND_CHANNEL_ADD_POLICY, 10101); + assert!(ALL_KINDS.contains(&KIND_CHANNEL_ADD_POLICY)); + assert!(is_replaceable(KIND_CHANNEL_ADD_POLICY)); + assert!(!is_parameterized_replaceable(KIND_CHANNEL_ADD_POLICY)); + } + #[test] fn nip43_membership_snapshot_is_relay_only() { assert!(is_relay_only_kind(KIND_NIP43_MEMBERSHIP_LIST)); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4..028769cc41 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -14,26 +14,27 @@ use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - 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_PROJECT, 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_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_CANVAS, KIND_CHANNEL_ADD_POLICY, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, 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_PROJECT, 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_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -289,7 +290,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), + | KIND_AGENT_PROFILE + | KIND_CHANNEL_ADD_POLICY => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -469,6 +471,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_EVENT_REMINDER // Agent profile (10100): user-owned replaceable, keyed by pubkey. | KIND_AGENT_PROFILE + // Channel-add policy (10101): user-owned replaceable, keyed by pubkey. + | KIND_CHANNEL_ADD_POLICY // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA // NIP-AP: team (30176) + managed-agent (30177) definitions and the @@ -3396,6 +3400,7 @@ mod tests { KIND_EMOJI_LIST, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_CHANNEL_ADD_POLICY, KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, @@ -3428,6 +3433,18 @@ mod tests { } } + #[test] + fn channel_add_policy_is_global_only_and_requires_users_write() { + let dummy = make_dummy_event(); + + assert!(is_global_only_kind(KIND_CHANNEL_ADD_POLICY)); + assert!(!requires_h_channel_scope(KIND_CHANNEL_ADD_POLICY)); + assert_eq!( + required_scope_for_kind(KIND_CHANNEL_ADD_POLICY, &dummy).unwrap(), + Scope::UsersWrite + ); + } + #[test] fn agent_turn_metric_is_global_only_and_in_scope_allowlist() { let dummy = make_dummy_event(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa84..a50812d083 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -7,11 +7,11 @@ use tracing::{info, warn}; 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, + event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_CHANNEL_ADD_POLICY, + 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, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -33,7 +33,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | KIND_CHANNEL_ADD_POLICY | 41001..=41003 | 40099) } async fn evict_live_channel_subscriptions( @@ -211,7 +211,15 @@ pub async fn handle_side_effects( 9022 => handle_leave_request(tenant, event, state).await, // NIP-34: Git repo announcement → reserve name + seed manifest pointer. KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(tenant, event, state).await, - KIND_AGENT_PROFILE => handle_agent_profile(tenant, event, state).await, + KIND_CHANNEL_ADD_POLICY => { + let policy = channel_add_policy_from_event(KIND_CHANNEL_ADD_POLICY, event)? + .ok_or_else(|| anyhow::anyhow!("kind:10101 missing channel_add_policy field"))?; + handle_channel_add_policy(tenant, event, state, &policy).await + } + KIND_AGENT_PROFILE => match channel_add_policy_from_event(KIND_AGENT_PROFILE, event)? { + Some(policy) => handle_channel_add_policy(tenant, event, state, &policy).await, + None => Ok(()), + }, // kind:7 (reaction) handled inline in ingest_event() before storage. _ => Ok(()), } @@ -1158,19 +1166,31 @@ pub async fn emit_group_discovery_events( Ok(()) } -async fn handle_agent_profile( +fn channel_add_policy_from_event(kind: u32, event: &Event) -> anyhow::Result> { + let content: serde_json::Value = match serde_json::from_str(&event.content) { + Ok(content) => content, + Err(_) if kind == KIND_AGENT_PROFILE => return Ok(None), + Err(error) => return Err(anyhow::anyhow!("kind:{kind} content parse error: {error}")), + }; + + match content.get("channel_add_policy") { + Some(policy) => policy + .as_str() + .map(|policy| Some(policy.to_string())) + .ok_or_else(|| anyhow::anyhow!("kind:{kind} channel_add_policy must be a string")), + None if kind == KIND_AGENT_PROFILE => Ok(None), + None => Err(anyhow::anyhow!( + "kind:{kind} missing channel_add_policy field" + )), + } +} + +async fn handle_channel_add_policy( tenant: &TenantContext, event: &Event, state: &Arc, + policy: &str, ) -> anyhow::Result<()> { - let content: serde_json::Value = serde_json::from_str(&event.content) - .map_err(|e| anyhow::anyhow!("kind:10100 content parse error: {e}"))?; - - let policy = content - .get("channel_add_policy") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("kind:10100 missing channel_add_policy field"))?; - let pubkey_bytes = event.pubkey.to_bytes().to_vec(); if state .db @@ -1188,7 +1208,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(pubkey = %hex::encode(&pubkey_bytes), policy, "channel_add_policy updated"); Ok(()) } @@ -3373,6 +3393,108 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn channel_add_policy_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + async fn channel_add_policy_tenant(state: &Arc) -> TenantContext { + let host = format!("channel-add-policy-{}.example", Uuid::new_v4().simple()); + let record = state + .db + .ensure_configured_community(&host) + .await + .expect("community"); + TenantContext::resolved(record.id, host) + } + + fn policy_event(kind: u32, content: &str) -> Event { + let keys = nostr::Keys::generate(); + EventBuilder::new(Kind::Custom(kind as u16), content) + .sign_with_keys(&keys) + .expect("sign") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn legacy_agent_profile_channel_add_policy_still_applies_policy() { + let state = channel_add_policy_test_state().await; + let tenant = channel_add_policy_tenant(&state).await; + let event = policy_event(KIND_AGENT_PROFILE, r#"{"channel_add_policy":"owner_only"}"#); + + handle_side_effects(&tenant, KIND_AGENT_PROFILE, &event, &state) + .await + .expect("legacy policy side effect"); + + let stored = state + .db + .get_agent_channel_policy(tenant.community(), &event.pubkey.to_bytes()) + .await + .expect("read policy"); + assert_eq!( + stored.map(|(policy, _)| policy), + Some("owner_only".to_string()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn agent_profile_without_channel_add_policy_is_a_no_op() { + let state = channel_add_policy_test_state().await; + let tenant = channel_add_policy_tenant(&state).await; + let event = policy_event(KIND_AGENT_PROFILE, r#"{"name":"Directory agent"}"#); + + handle_side_effects(&tenant, KIND_AGENT_PROFILE, &event, &state) + .await + .expect("agent profile side effect"); + + assert!(state + .db + .get_agent_channel_policy(tenant.community(), &event.pubkey.to_bytes()) + .await + .expect("read policy") + .is_none()); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content =