From c3084b36d975259f2dfeee8edc9131b40a8bce83 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Mon, 27 Jul 2026 13:10:42 -0400 Subject: [PATCH] fix(cli,relay): resolve agents by verified owner (#2615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context `buzz users get --name Honey` searches relay-wide profiles and returns up to 100 identically named results without verified ownership metadata. An agent resolving “my Honey” cannot distinguish the requesting human's agent from another owner's agent, and the owned match can be excluded by the result limit. This caused the wrong Honey and Bumble pubkeys to be added to a channel. ## Summary This bug fix makes personal-agent resolution owner-aware. Callers can filter profiles by a verified owner identity before result limits are applied, and all profile results expose enough ownership context to diagnose duplicate names. ## Changes - Adds `buzz users get --owner me||` for name and pubkey lookups. - Resolves `me` to the NIP-OA owner identity when the CLI runs as an agent. - Filters profiles by the relay's verified `agent_owner_pubkey` relationship before applying the result limit. - Returns `owner_pubkey`, `owner_display_name`, and client-relative `owned_by_me` in compact and JSON output. - Returns an empty result when no owned profile matches instead of removing the ownership constraint. - Rejects malformed owner values instead of silently running an unscoped query; explicit `null` remains equivalent to no owner filter for ordinary CLI lookups. - Rejects owner constraints on specialized channel-window, feed, and thread filters that cannot enforce author filtering. - Scopes owner filtering and enrichment to the active community. - Adds a partial `(community_id, agent_owner_pubkey)` index for owner lookups. - Documents the safe `users get --name Honey --owner me` lookup. ## Reviewer-reproducible examples The relay-backed test creates two same-name agents with different verified owners, queries through the HTTP `/query` route, verifies only the selected owner's agent is returned with verified owner metadata, and verifies a missing owner returns `[]`. ```bash cargo test -p buzz-relay query_agent_owner_returns_only_verified_owner_matches --lib -- --ignored ``` The owner/author intersection and unsupported-specialized-filter contracts also have infrastructure-free relay tests: ```bash cargo test -p buzz-relay agent_owner --lib ``` The CLI surface is visible in command help: ```bash cargo run -q -p buzz-cli -- users get --help | grep -- --owner ``` ```text --owner Filter agents by verified owner (`me`, 64-char hex, or npub) ``` ## Validation - `cargo test -p buzz-cli` (252 passed) - `cargo test -p buzz-db` (84 passed, 122 infrastructure tests ignored) - `cargo test -p buzz-relay --lib` (owner-filter tests pass; the full local suite is blocked by unrelated Postgres pool timeouts in media/admin tests) - `cargo test -p buzz-relay query_agent_owner_returns_only_verified_owner_matches --lib -- --ignored` (passed) - `cargo check --workspace --all-targets` - `cargo fmt --all -- --check` - Pre-push Rust, Desktop, and Desktop Tauri suites passed - Pre-push mobile suite could not start because `flutter` is not installed - `pnpm check:file-sizes` (passed after rebasing onto current `main`) --------- Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- crates/buzz-cli/README.md | 1 + crates/buzz-cli/src/commands/users.rs | 101 +++++- crates/buzz-cli/src/lib.rs | 3 + crates/buzz-db/src/lib.rs | 18 + crates/buzz-db/src/migration.rs | 2 +- crates/buzz-db/src/user.rs | 121 +++++++ crates/buzz-relay/src/api/bridge.rs | 328 ++++++++++++++++++- migrations/0025_users_agent_owner_lookup.sql | 3 + 8 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 migrations/0025_users_agent_owner_lookup.sql diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..f5ab0135c3 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -56,6 +56,7 @@ buzz reactions get --event buzz users get # your own profile buzz users get --pubkey # single user buzz users get --pubkey --pubkey # batch (max 200) +buzz users get --name Honey --owner me # exact-name lookup scoped to your verified owner buzz users set-presence --status online # DMs diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..2d36929b8d 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,3 +1,5 @@ +use nostr::PublicKey; + use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -13,6 +15,7 @@ pub async fn cmd_get_users( client: &BuzzClient, pubkeys: &[String], name: Option<&str>, + owner: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { if let Some(query) = name { @@ -21,7 +24,7 @@ pub async fn cmd_get_users( "--name and --pubkey are mutually exclusive".into(), )); } - return search_by_name(client, query, format).await; + return search_by_name(client, query, owner, format).await; } for pk in pubkeys { @@ -38,12 +41,18 @@ pub async fn cmd_get_users( pubkeys.iter().map(|s| s.as_str()).collect() }; + let owner = resolve_owner(client, owner)?; let filter = serde_json::json!({ "kinds": [0], "authors": authors, - "limit": authors.len() + "limit": authors.len(), + "include_agent_owner": true, + "agent_owner": owner, }); let resp = client.query(&filter).await?; + let my_owner_pubkey = client + .auth_tag_owner_hex() + .unwrap_or_else(|| client.keys().public_key().to_hex()); let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); let profiles: Vec = events .iter() @@ -55,6 +64,7 @@ pub async fn cmd_get_users( "pubkey".to_string(), serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), ); + copy_owner_fields(e, obj, &my_owner_pubkey); } Some(profile) }) @@ -66,6 +76,9 @@ pub async fn cmd_get_users( .map(|p| serde_json::json!({ "pubkey": p.get("pubkey").cloned().unwrap_or_default(), "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), + "owner_pubkey": p.get("owner_pubkey").cloned().unwrap_or_default(), + "owner_display_name": p.get("owner_display_name").cloned().unwrap_or_default(), + "owned_by_me": p.get("owned_by_me").cloned().unwrap_or_default(), })) .collect(); serde_json::to_string(&compact).unwrap_or_default() @@ -76,23 +89,70 @@ pub async fn cmd_get_users( Ok(()) } +fn copy_owner_fields( + event: &serde_json::Value, + profile: &mut serde_json::Map, + my_owner_pubkey: &str, +) { + if let Some(value) = event.get("agent_owner_pubkey") { + profile.insert("owner_pubkey".to_string(), value.clone()); + } + if let Some(value) = event.get("agent_owner_display_name") { + profile.insert("owner_display_name".to_string(), value.clone()); + } + if let Some(owner) = event + .get("agent_owner_pubkey") + .and_then(|value| value.as_str()) + { + profile.insert( + "owned_by_me".to_string(), + serde_json::json!(owner == my_owner_pubkey), + ); + } +} + +fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result, CliError> { + owner + .map(|owner| { + if owner == "me" { + Ok(client + .auth_tag_owner_hex() + .unwrap_or_else(|| client.keys().public_key().to_hex())) + } else { + PublicKey::parse(owner) + .map(|pubkey| pubkey.to_hex()) + .map_err(|e| { + CliError::Usage(format!("--owner must be `me`, a pubkey, or npub: {e}")) + }) + } + }) + .transpose() +} + /// Search for users by display name via NIP-50 full-text search on kind:0 profiles. /// Returns [] if the relay does not implement NIP-50 search. async fn search_by_name( client: &BuzzClient, query: &str, + owner: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { if query.trim().is_empty() { return Err(CliError::Usage("--name cannot be empty".into())); } + let owner = resolve_owner(client, owner)?; let filter = serde_json::json!({ "kinds": [0], "search": query, - "limit": 100 + "limit": 100, + "include_agent_owner": true, + "agent_owner": owner, }); let raw = client.query(&filter).await?; + let my_owner_pubkey = client + .auth_tag_owner_hex() + .unwrap_or_else(|| client.keys().public_key().to_hex()); // Parse and filter client-side for case-insensitive substring match // on display_name or name fields (NIP-50 may return broader matches). @@ -126,6 +186,7 @@ async fn search_by_name( "pubkey".to_string(), serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), ); + copy_owner_fields(event, obj, &my_owner_pubkey); } Some(profile) }) @@ -137,6 +198,9 @@ async fn search_by_name( .map(|p| serde_json::json!({ "pubkey": p.get("pubkey").cloned().unwrap_or_default(), "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), + "owner_pubkey": p.get("owner_pubkey").cloned().unwrap_or_default(), + "owner_display_name": p.get("owner_display_name").cloned().unwrap_or_default(), + "owned_by_me": p.get("owned_by_me").cloned().unwrap_or_default(), })) .collect(); serde_json::to_string(&compact).unwrap_or_default() @@ -311,9 +375,11 @@ pub async fn dispatch( ) -> Result<(), CliError> { use crate::UsersCmd; match cmd { - UsersCmd::Get { pubkeys, name } => { - cmd_get_users(client, &pubkeys, name.as_deref(), format).await - } + UsersCmd::Get { + pubkeys, + name, + owner, + } => cmd_get_users(client, &pubkeys, name.as_deref(), owner.as_deref(), format).await, UsersCmd::SetProfile { name, avatar, @@ -336,9 +402,30 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::presence_subject; + use super::{copy_owner_fields, presence_subject}; use serde_json::json; + #[test] + fn copy_owner_fields_marks_authenticated_owner() { + let owner = "a".repeat(64); + let event = json!({ + "agent_owner_pubkey": owner, + "agent_owner_display_name": "John", + }); + let mut profile = serde_json::Map::new(); + copy_owner_fields(&event, &mut profile, &"a".repeat(64)); + assert_eq!(profile["owner_display_name"], "John"); + assert_eq!(profile["owned_by_me"], true); + } + + #[test] + fn copy_owner_fields_marks_other_owner() { + let event = json!({"agent_owner_pubkey": "a".repeat(64)}); + let mut profile = serde_json::Map::new(); + copy_owner_fields(&event, &mut profile, &"b".repeat(64)); + assert_eq!(profile["owned_by_me"], false); + } + #[test] fn presence_subject_uses_p_tag() { let event = json!({"pubkey": "relay", "tags": [["p", "user"]]}); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..1571d74e28 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -808,6 +808,9 @@ pub enum UsersCmd { /// Search by display name (case-insensitive substring match) #[arg(long = "name")] name: Option, + /// Filter agents by verified owner (`me`, 64-char hex, or npub) + #[arg(long = "owner")] + owner: Option, }, /// Update the current identity's profile #[command(name = "set-profile")] diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9c63b2e8ab..1df6045032 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1843,6 +1843,24 @@ impl Db { user::search_users(&self.pool, community_id, query, limit).await } + /// Return agent pubkeys whose verified owner matches `owner_pubkey`. + pub async fn list_agent_pubkeys_by_owner( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + ) -> Result>> { + user::list_agent_pubkeys_by_owner(&self.pool, community_id, owner_pubkey).await + } + + /// Fetch verified ownership metadata for the requested agent pubkeys. + pub async fn get_agent_owners( + &self, + community_id: CommunityId, + agent_pubkeys: &[Vec], + ) -> Result> { + user::get_agent_owners(&self.pool, community_id, agent_pubkeys).await + } + /// Atomically set agent owner — only if no owner is currently assigned. /// Returns Ok(true) if set, Ok(false) if an owner already exists. pub async fn set_agent_owner( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..3605ea98dc 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -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] diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 066fb5f5c0..f477c78f76 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -5,6 +5,17 @@ use buzz_core::CommunityId; use sqlx::PgPool; use sqlx::Row; +/// Ownership metadata for an agent profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentOwner { + /// Raw agent public key. + pub agent_pubkey: Vec, + /// Raw owner public key, verified when the agent authenticated via NIP-OA. + pub owner_pubkey: Vec, + /// Owner's current display name, when available. + pub owner_display_name: Option, +} + /// A user's profile fields. #[derive(Debug, Clone)] pub struct UserProfile { @@ -280,6 +291,65 @@ pub async fn search_users( .collect()) } +/// Return agent pubkeys whose verified owner matches `owner_pubkey`. +pub async fn list_agent_pubkeys_by_owner( + pool: &PgPool, + community_id: CommunityId, + owner_pubkey: &[u8], +) -> Result>> { + let rows = sqlx::query_scalar::<_, Vec>( + r#" + SELECT pubkey + FROM users + WHERE community_id = $1 AND agent_owner_pubkey = $2 + "#, + ) + .bind(community_id.as_uuid()) + .bind(owner_pubkey) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Fetch verified ownership metadata for the requested agent pubkeys. +pub async fn get_agent_owners( + pool: &PgPool, + community_id: CommunityId, + agent_pubkeys: &[Vec], +) -> Result> { + if agent_pubkeys.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, (Vec, Vec, Option)>( + r#" + SELECT agent.pubkey, agent.agent_owner_pubkey, owner.display_name + FROM users agent + LEFT JOIN users owner + ON owner.community_id = agent.community_id + AND owner.pubkey = agent.agent_owner_pubkey + WHERE agent.community_id = $1 + AND agent.pubkey = ANY($2) + AND agent.agent_owner_pubkey IS NOT NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkeys) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map( + |(agent_pubkey, owner_pubkey, owner_display_name)| AgentOwner { + agent_pubkey, + owner_pubkey, + owner_display_name, + }, + ) + .collect()) +} + /// Set the owner pubkey for an agent user. /// The owner pubkey must already exist in the users table (FK constraint). /// Returns an error if the agent pubkey is not found (rows_affected == 0). @@ -464,6 +534,57 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_agent_owner_queries_are_scoped_and_include_display_name() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent_pk = random_pubkey(); + let other_agent_pk = random_pubkey(); + let owner_pk = random_pubkey(); + let other_owner_pk = random_pubkey(); + for pubkey in [&agent_pk, &other_agent_pk, &owner_pk, &other_owner_pk] { + ensure_user(&db.pool, community, pubkey).await.unwrap(); + } + update_user_profile( + &db.pool, + community, + &owner_pk, + Some("Owner Name"), + None, + None, + None, + ) + .await + .unwrap(); + set_agent_owner(&db.pool, community, &agent_pk, &owner_pk) + .await + .unwrap(); + set_agent_owner(&db.pool, community, &other_agent_pk, &other_owner_pk) + .await + .unwrap(); + + assert_eq!( + list_agent_pubkeys_by_owner(&db.pool, community, &owner_pk) + .await + .unwrap(), + vec![agent_pk.clone()] + ); + assert_eq!( + get_agent_owners(&db.pool, community, &[agent_pk.clone(), other_agent_pk],) + .await + .unwrap() + .into_iter() + .find(|owner| owner.agent_pubkey == agent_pk) + .unwrap(), + AgentOwner { + agent_pubkey: agent_pk, + owner_pubkey: owner_pk, + owner_display_name: Some("Owner Name".into()), + } + ); + } + /// set_channel_add_policy should persist each of the three valid policies. #[tokio::test] #[ignore = "requires Postgres"] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c8ec0cdbc9..9cfa878d46 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -280,6 +280,112 @@ fn extension_flag(raw: &Value, key: &str) -> bool { raw.get(key).and_then(Value::as_bool).unwrap_or(false) } +fn extract_agent_owner(raw: &Value) -> Result>, (StatusCode, Json)> { + let Some(value) = raw.get("agent_owner") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let owner = value.as_str().ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "agent_owner must be a 64-char hex pubkey", + ) + })?; + let bytes = hex::decode(owner) + .ok() + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "agent_owner must be a 64-char hex pubkey", + ) + })?; + Ok(Some(bytes)) +} + +fn include_agent_owner(raw_filters: &[Value]) -> bool { + raw_filters + .iter() + .any(|raw| extension_flag(raw, "include_agent_owner")) +} + +fn apply_agent_owner_authors(raw: &mut Value, owned_pubkeys: &[Vec]) { + let mut owned_hex: std::collections::HashSet = + owned_pubkeys.iter().map(hex::encode).collect(); + if let Some(requested) = raw.get("authors").and_then(Value::as_array) { + let requested: std::collections::HashSet<&str> = + requested.iter().filter_map(Value::as_str).collect(); + owned_hex.retain(|pubkey| requested.contains(pubkey.as_str())); + } + raw["authors"] = Value::Array(owned_hex.into_iter().map(Value::String).collect()); +} + +fn reject_unsupported_agent_owner_filter(raw: &Value) -> Result<(), (StatusCode, Json)> { + if raw.get("agent_owner").is_some() + && (extension_flag(raw, "top_level") + || raw.get("feed_types").is_some() + || raw.get("depth_limit").is_some()) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "agent_owner is not supported with channel-window, feed, or thread filters", + )); + } + Ok(()) +} + +async fn enrich_agent_owners( + state: &AppState, + tenant: &buzz_core::tenant::TenantContext, + events: &mut [Value], +) -> Result<(), (StatusCode, Json)> { + let pubkeys: Vec> = events + .iter() + .filter_map(|event| event.get("pubkey").and_then(Value::as_str)) + .filter_map(|pubkey| hex::decode(pubkey).ok()) + .collect(); + let owners = state + .db + .get_agent_owners(tenant.community(), &pubkeys) + .await + .map_err(|e| internal_error(&format!("agent owner lookup error: {e}")))?; + let owners: std::collections::HashMap, buzz_db::user::AgentOwner> = owners + .into_iter() + .map(|owner| (owner.agent_pubkey.clone(), owner)) + .collect(); + + for event in events { + let Some(agent_pubkey) = event + .get("pubkey") + .and_then(Value::as_str) + .and_then(|pubkey| hex::decode(pubkey).ok()) + else { + continue; + }; + let Some(owner) = owners.get(&agent_pubkey) else { + continue; + }; + let Some(object) = event.as_object_mut() else { + continue; + }; + object.insert( + "agent_owner_pubkey".into(), + Value::String(hex::encode(&owner.owner_pubkey)), + ); + object.insert( + "agent_owner_display_name".into(), + owner + .owner_display_name + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + ); + } + Ok(()) +} + fn extract_depth_limit(raw: &Value) -> Option { raw.get("depth_limit")? .as_u64() @@ -967,8 +1073,20 @@ async fn query_events_authed( // Two-pass parse: preserve raw JSON for custom extension fields (before_id, // depth_limit, feed_types) that nostr::Filter silently drops. - let raw_filters: Vec = serde_json::from_slice(body) + let mut raw_filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + for raw in &mut raw_filters { + reject_unsupported_agent_owner_filter(raw)?; + let Some(owner_pubkey) = extract_agent_owner(raw)? else { + continue; + }; + let owned_pubkeys = state + .db + .list_agent_pubkeys_by_owner(tenant.community(), &owner_pubkey) + .await + .map_err(|e| internal_error(&format!("agent owner filter error: {e}")))?; + apply_agent_owner_authors(raw, &owned_pubkeys); + } let filters: Vec = raw_filters .iter() .map(|v| serde_json::from_value(v.clone())) @@ -1205,6 +1323,14 @@ async fn query_events_authed( if handled.contains(&idx) { continue; } + if raw.get("agent_owner").is_some() + && raw + .get("authors") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + { + continue; + } if let Some(ch_id) = extract_channel_from_filter(filter) { if !accessible_channels.contains(&ch_id) { @@ -1306,6 +1432,9 @@ async fn query_events_authed( } } + if include_agent_owner(&raw_filters) { + enrich_agent_owners(state, tenant, &mut events).await?; + } Ok(Json(Value::Array(events))) } @@ -1650,6 +1779,14 @@ async fn handle_bridge_search( for (raw, filter) in raw_filters.iter().zip(filters) { let search_mode = extract_search_mode(raw); let search_page = extract_search_page(raw); + if raw.get("agent_owner").is_some() + && raw + .get("authors") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + { + continue; + } let search_text = match &filter.search { Some(s) if !s.is_empty() => s.clone(), _ => continue, @@ -1763,6 +1900,9 @@ async fn handle_bridge_search( } } + if include_agent_owner(raw_filters) { + enrich_agent_owners(state, tenant, &mut events).await?; + } Ok(Json(Value::Array(events))) } @@ -2788,6 +2928,66 @@ mod tests { ); } + #[test] + fn extract_agent_owner_accepts_hex_and_rejects_invalid_values() { + let owner = "a".repeat(64); + assert_eq!( + extract_agent_owner(&serde_json::json!({"agent_owner": owner})) + .unwrap() + .unwrap(), + vec![0xaa; 32] + ); + assert!(extract_agent_owner(&serde_json::json!({"agent_owner": "bad"})).is_err()); + assert!(extract_agent_owner(&serde_json::json!({"agent_owner": true})).is_err()); + assert!(extract_agent_owner(&serde_json::json!({"agent_owner": 42})).is_err()); + assert_eq!( + extract_agent_owner(&serde_json::json!({"agent_owner": null})).unwrap(), + None + ); + assert_eq!(extract_agent_owner(&serde_json::json!({})).unwrap(), None); + } + + #[test] + fn agent_owner_authors_fail_closed_and_intersect_requested_authors() { + let agent_a = vec![0xaa; 32]; + let agent_b = vec![0xbb; 32]; + let owned = vec![agent_a.clone(), agent_b]; + + let mut unscoped = serde_json::json!({}); + apply_agent_owner_authors(&mut unscoped, &owned); + let authors = unscoped["authors"].as_array().unwrap(); + assert_eq!(authors.len(), 2); + assert!(authors.contains(&serde_json::json!(hex::encode(&agent_a)))); + + let mut intersected = serde_json::json!({ + "authors": [hex::encode(&agent_a), "cc".repeat(32)] + }); + apply_agent_owner_authors(&mut intersected, &owned); + assert_eq!( + intersected["authors"], + serde_json::json!([hex::encode(agent_a)]) + ); + + let mut empty = serde_json::json!({"authors": ["cc".repeat(32)]}); + apply_agent_owner_authors(&mut empty, &owned); + assert_eq!(empty["authors"], serde_json::json!([])); + } + + #[test] + fn agent_owner_rejects_specialized_filters_that_cannot_enforce_authors() { + for raw in [ + serde_json::json!({"agent_owner": "aa".repeat(32), "top_level": true}), + serde_json::json!({"agent_owner": "aa".repeat(32), "feed_types": ["activity"]}), + serde_json::json!({"agent_owner": "aa".repeat(32), "depth_limit": 1}), + ] { + assert!(reject_unsupported_agent_owner_filter(&raw).is_err()); + } + assert!(reject_unsupported_agent_owner_filter( + &serde_json::json!({"agent_owner": "aa".repeat(32), "kinds": [0]}) + ) + .is_ok()); + } + /// `nip42_expected_relay_url` derives scheme from `config_relay_url`'s /// prefix: `wss://` → `wss`, everything else → `ws`. Deployments that run /// `ws://` in dev/test must produce a `ws://` URL that matches what @@ -3355,6 +3555,36 @@ mod tests { /// Drive a single POST /events request through the router and return the /// HTTP status code. + async fn post_query( + state: Arc, + host: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, Value) { + use axum::body::{to_bytes, Body}; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let response = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/query") + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + let body = serde_json::from_slice(&bytes).expect("parse response JSON"); + (status, body) + } + async fn post_events( state: Arc, host: &str, @@ -3380,6 +3610,102 @@ mod tests { .status() } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn query_agent_owner_returns_only_verified_owner_matches() { + let state = bridge_handler_test_state() + .await + .expect("local Postgres and Redis required"); + let host = format!("bridge-owner-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let requester = Keys::generate(); + let owner_a = Keys::generate(); + let owner_b = Keys::generate(); + let agent_a = Keys::generate(); + let agent_b = Keys::generate(); + + for keys in [&requester, &owner_a, &owner_b, &agent_a, &agent_b] { + state + .db + .ensure_user(community, &keys.public_key().to_bytes()) + .await + .expect("ensure user"); + } + state + .db + .set_agent_owner( + community, + &agent_a.public_key().to_bytes(), + &owner_a.public_key().to_bytes(), + ) + .await + .expect("set first owner"); + state + .db + .set_agent_owner( + community, + &agent_b.public_key().to_bytes(), + &owner_b.public_key().to_bytes(), + ) + .await + .expect("set second owner"); + + for keys in [&agent_a, &agent_b] { + let event = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Honey"}"#) + .sign_with_keys(keys) + .expect("sign profile"); + state + .db + .insert_event(community, &event, None) + .await + .expect("insert profile"); + } + + let filter = serde_json::json!([{ + "kinds": [0], + "search": "Honey", + "agent_owner": owner_a.public_key().to_hex(), + "include_agent_owner": true, + "limit": 100 + }]); + let (status, body) = post_query( + state.clone(), + &host, + &requester.public_key().to_hex(), + filter.to_string().as_bytes(), + ) + .await; + assert_eq!(status, StatusCode::OK, "query response: {body}"); + let results = body.as_array().expect("array response"); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["pubkey"], agent_a.public_key().to_hex()); + assert_eq!( + results[0]["agent_owner_pubkey"], + owner_a.public_key().to_hex() + ); + + let no_match = serde_json::json!([{ + "kinds": [0], + "search": "Honey", + "agent_owner": requester.public_key().to_hex(), + "limit": 100 + }]); + let (status, body) = post_query( + state, + &host, + &requester.public_key().to_hex(), + no_match.to_string().as_bytes(), + ) + .await; + assert_eq!(status, StatusCode::OK, "query response: {body}"); + assert_eq!(body, serde_json::json!([])); + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( diff --git a/migrations/0025_users_agent_owner_lookup.sql b/migrations/0025_users_agent_owner_lookup.sql new file mode 100644 index 0000000000..1a55e49e03 --- /dev/null +++ b/migrations/0025_users_agent_owner_lookup.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_users_agent_owner + ON users (community_id, agent_owner_pubkey) + WHERE agent_owner_pubkey IS NOT NULL;