mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(cli,relay): resolve agents by verified owner (#2615)
## 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|<hex>|<npub>` 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 <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>
This commit is contained in:
committed by
GitHub
parent
8bb43d5191
commit
c3084b36d9
@@ -56,6 +56,7 @@ buzz reactions get --event <event-id>
|
||||
buzz users get # your own profile
|
||||
buzz users get --pubkey <hex> # single user
|
||||
buzz users get --pubkey <hex> --pubkey <hex> # 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
|
||||
|
||||
@@ -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::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
let profiles: Vec<serde_json::Value> = 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<String, serde_json::Value>,
|
||||
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<Option<String>, 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"]]});
|
||||
|
||||
@@ -808,6 +808,9 @@ pub enum UsersCmd {
|
||||
/// Search by display name (case-insensitive substring match)
|
||||
#[arg(long = "name")]
|
||||
name: Option<String>,
|
||||
/// Filter agents by verified owner (`me`, 64-char hex, or npub)
|
||||
#[arg(long = "owner")]
|
||||
owner: Option<String>,
|
||||
},
|
||||
/// Update the current identity's profile
|
||||
#[command(name = "set-profile")]
|
||||
|
||||
@@ -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<Vec<Vec<u8>>> {
|
||||
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<u8>],
|
||||
) -> Result<Vec<user::AgentOwner>> {
|
||||
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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<u8>,
|
||||
/// Raw owner public key, verified when the agent authenticated via NIP-OA.
|
||||
pub owner_pubkey: Vec<u8>,
|
||||
/// Owner's current display name, when available.
|
||||
pub owner_display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<Vec<u8>>> {
|
||||
let rows = sqlx::query_scalar::<_, Vec<u8>>(
|
||||
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<u8>],
|
||||
) -> Result<Vec<AgentOwner>> {
|
||||
if agent_pubkeys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, (Vec<u8>, Vec<u8>, Option<String>)>(
|
||||
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"]
|
||||
|
||||
@@ -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<Option<Vec<u8>>, (StatusCode, Json<Value>)> {
|
||||
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<u8>]) {
|
||||
let mut owned_hex: std::collections::HashSet<String> =
|
||||
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<Value>)> {
|
||||
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<Value>)> {
|
||||
let pubkeys: Vec<Vec<u8>> = 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<Vec<u8>, 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<u32> {
|
||||
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<Value> = serde_json::from_slice(body)
|
||||
let mut raw_filters: Vec<Value> = 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<nostr::Filter> = 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<crate::state::AppState>,
|
||||
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<crate::state::AppState>,
|
||||
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(
|
||||
|
||||
3
migrations/0025_users_agent_owner_lookup.sql
Normal file
3
migrations/0025_users_agent_owner_lookup.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE INDEX idx_users_agent_owner
|
||||
ON users (community_id, agent_owner_pubkey)
|
||||
WHERE agent_owner_pubkey IS NOT NULL;
|
||||
Reference in New Issue
Block a user