From 7de57ff83c23789bc6261277942a831ed6edff86 Mon Sep 17 00:00:00 2001 From: Jon Gouveia Date: Mon, 3 Aug 2026 22:47:22 -0400 Subject: [PATCH 1/2] feat(acp): publish kind:10100 agent profile for directory discovery Clients build their agent directory from kind:10100 agent-profile events, but nothing published them. The desktop app publishes only a kind:0 profile for a managed agent, and channel-level bot membership (kind:39002) is not a source those directories read, so a running agent never appeared in the list and could not be tracked for presence. buzz-acp now publishes a signed kind:10100 profile over the authenticated POST /events bridge once channel discovery finishes, and republishes it when membership changes so channel_ids stays current. Content carries the name, respond_to mode, and allowlist that consumers use for mention eligibility. The name comes from the agent's own kind:0 display name, falling back to the normalized runtime command when no profile exists. Publishing is gated on the existing presence flag, and a failure only warns. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DUks9koR8d9L3gC9ARsSLy Signed-off-by: Jon Gouveia --- crates/buzz-acp/src/lib.rs | 249 ++++++++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..7dcd228078 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 @@ -1361,6 +1464,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); @@ -1432,7 +1556,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 => { @@ -1507,6 +1632,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}"), @@ -1959,6 +2097,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. @@ -4416,6 +4582,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::*; From 97e0b731144cb19600eb372b58588ce51437c2c7 Mon Sep 17 00:00:00 2001 From: Jon Gouveia Date: Tue, 4 Aug 2026 00:02:40 -0400 Subject: [PATCH 2/2] fix(core,relay,cli): give channel-add policy its own kind Kind 10100 was doing two jobs. The registry documents it as the agent profile, and the desktop and mobile clients both read it that way, but the relay also used it to carry a user's channel_add_policy and rejected any 10100 without that field. Since 10100 is replaceable, an agent profile and a policy silently overwrote each other. Adds KIND_CHANNEL_ADD_POLICY (10101) for the policy and leaves agent profiles on 10100. The relay accepts the new kind with the same UsersWrite scope and routes it to the policy handler, renamed to say what it does. Older clients still work: a 10100 carrying channel_add_policy is honored, and one without it is now a no-op instead of an error. The CLI publishes the new kind. No migration. The enforced policy lives in the users table and is written by the side effect, so existing rows stay valid. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DUks9koR8d9L3gC9ARsSLy Signed-off-by: Jon Gouveia --- crates/buzz-cli/src/commands/channels.rs | 6 +- crates/buzz-core/src/kind.rs | 15 ++ crates/buzz-relay/src/handlers/ingest.rs | 59 ++++--- .../buzz-relay/src/handlers/side_effects.rs | 156 ++++++++++++++++-- 4 files changed, 195 insertions(+), 41 deletions(-) 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 b1be7c5038..a7217885e0 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). /// @@ -637,6 +642,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, @@ -839,6 +845,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 @@ -892,6 +899,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 fcd0d70728..e12d270efa 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; @@ -240,7 +241,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 @@ -420,6 +422,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 @@ -3263,6 +3267,7 @@ mod tests { KIND_EMOJI_LIST, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_CHANNEL_ADD_POLICY, KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, @@ -3295,6 +3300,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 660a55fef3..26ef228ced 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 =