mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(cli): resolve agents from owner records (#3178)
## Context
`buzz users get --name Honey` searches relay-wide profiles and can
return an identically named agent owned by someone else. This caused
agents from the wrong owner to be added to a channel.
## Summary
This bug fix scopes exact-name agent lookup to owner-authored
managed-agent records, then cryptographically verifies each returned
profile's NIP-OA `auth` tag before asserting ownership. The relay and
database contracts remain unchanged.
### Related issue
None found.
## Changes
- Adds `buzz users get --name Honey --owner me|<hex>|<npub>`.
- Resolves `me` to the NIP-OA owner when the CLI runs as an agent,
otherwise to the CLI identity.
- Matches kind `30177` managed-agent record names exactly and
case-insensitively under the requested owner.
- Requires exactly one valid NIP-OA `auth` tag whose verified owner
equals the requested owner and whose `kind` and `created_at` conditions
apply to the profile event before returning `owner_pubkey` or
`owned_by_me: true`.
- Keeps missing, malformed, stale, condition-mismatched, or unverifiable
owner-record candidates visible with `owned_by_me: false` and an
explicit `verification` value.
- Returns every same-name record for the owner so callers can require
explicit selection when duplicates remain.
- Preserves the existing output shape and client-side name filter for
unscoped searches.
- Documents the distinct owner-scoped managed-agent lookup and unscoped
NIP-50 lookup modes.
### Testing
The reviewer-reproducible red and green commands below exercise the
ownership bug against the target branch and this branch.
## Screenshots
Not applicable. This is a CLI-only change.
## Reviewer-reproducible examples
The lookups below were run against the live relay from `main` and this
branch.
### Red: unscoped lookup returns the 100-profile relay-wide cap and
excludes John's agents
On `main`:
```bash
cargo run -q -p buzz-cli -- users get --name Honey \
| jq '{count: length, first_three: .[:3] | map(.pubkey), johns_agents: map(select(.pubkey == "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7" or .pubkey == "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54"))}'
```
Observed output:
```json
{
"count": 100,
"first_three": [
"20d27fc6c0ab4f50b66d1a32a64c5ca1fb985254143ce911f61ab7733333c3d7",
"00644478cdd9032c563ddc712b3687d8345d948945aab3c18bab95afbf6f519a",
"93c16697d0e58007bc11fb953208bc6b1cff387b2dee094abc10bf82dfee5424"
],
"johns_agents": []
}
```
`main` also rejects the owner-scoped command:
```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me
```
```text
error: unexpected argument '--owner' found
Usage: buzz users get --name <NAME>
```
### Green: owner-scoped lookup distinguishes verified and unresolved
records
On this branch:
```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me \
| jq 'map({pubkey,display_name,owner_pubkey,owned_by_me,verification})'
```
Observed output:
```json
[
{
"pubkey": "0ca77314d7ac8b3fcf6c647cc8cb9c3afd840db3b2a8ff2079f09a168de1827e",
"display_name": null,
"owner_pubkey": null,
"owned_by_me": false,
"verification": "missing_profile"
},
{
"pubkey": "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7",
"display_name": "Honey",
"owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
"owned_by_me": true,
"verification": "verified"
},
{
"pubkey": "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54",
"display_name": "Honey",
"owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
"owned_by_me": true,
"verification": "verified"
}
]
```
Only the two profiles with valid NIP-OA proofs assert ownership. The
owner-authored record whose profile is absent remains visible but cannot
be selected as verified ownership.
---------
Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k
parent
3b8567a05d
commit
262f2392e3
@@ -276,6 +276,7 @@ test-unit:
|
||||
#!/usr/bin/env bash
|
||||
if command -v cargo-nextest &>/dev/null; then
|
||||
cargo nextest run -p buzz-core -p buzz-auth --lib
|
||||
cargo nextest run -p buzz-cli
|
||||
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
|
||||
# They guard the embedded-migrator invariant (exactly the consolidated
|
||||
# 0001; cutover/backfill stays an operator script, not startup state)
|
||||
|
||||
@@ -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 in your managed agents
|
||||
buzz users set-presence --status online
|
||||
buzz users set-status --text "heads down on the CLI" --emoji "🚀"
|
||||
buzz users set-status --clear # remove your status
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::client::{normalize_write_response, BuzzClient};
|
||||
use buzz_core::kind::KIND_MANAGED_AGENT;
|
||||
use nostr::PublicKey;
|
||||
|
||||
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
|
||||
use crate::error::CliError;
|
||||
use crate::validate::validate_hex64;
|
||||
|
||||
@@ -13,6 +16,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 +25,11 @@ 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;
|
||||
}
|
||||
|
||||
if owner.is_some() {
|
||||
return Err(CliError::Usage("--owner requires --name".into()));
|
||||
}
|
||||
|
||||
for pk in pubkeys {
|
||||
@@ -76,68 +84,269 @@ pub async fn cmd_get_users(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn effective_owner(client: &BuzzClient) -> String {
|
||||
client
|
||||
.auth_tag_owner_hex()
|
||||
.unwrap_or_else(|| client.keys().public_key().to_hex())
|
||||
}
|
||||
|
||||
fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result<Option<String>, CliError> {
|
||||
owner
|
||||
.map(|owner| {
|
||||
if owner == "me" {
|
||||
Ok(effective_owner(client))
|
||||
} 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()
|
||||
}
|
||||
|
||||
fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> Vec<String> {
|
||||
let mut pubkeys: Vec<String> = events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(event.get("content")?.as_str()?).ok()?;
|
||||
let name = content.get("name")?.as_str()?;
|
||||
if !name.eq_ignore_ascii_case(query) {
|
||||
return None;
|
||||
}
|
||||
let pubkey = extract_d_tag(event);
|
||||
(!pubkey.is_empty()).then_some(pubkey)
|
||||
})
|
||||
.collect();
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
pubkeys
|
||||
}
|
||||
|
||||
async fn owned_agent_pubkeys_by_name(
|
||||
client: &BuzzClient,
|
||||
owner: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [KIND_MANAGED_AGENT],
|
||||
"authors": [owner],
|
||||
});
|
||||
let events = client.query_all(filter).await?;
|
||||
Ok(owned_agent_pubkeys_from_events(&events, query))
|
||||
}
|
||||
|
||||
fn profile_content(event: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
event
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
|
||||
.and_then(|content| content.as_object().cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec<serde_json::Value> {
|
||||
let lower_query = query.to_ascii_lowercase();
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let mut profile = profile_content(event);
|
||||
let display_name = profile
|
||||
.get("display_name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
let name = profile
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
if !display_name.to_ascii_lowercase().contains(&lower_query)
|
||||
&& !name.to_ascii_lowercase().contains(&lower_query)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
profile.insert(
|
||||
"pubkey".to_string(),
|
||||
serde_json::json!(event
|
||||
.get("pubkey")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")),
|
||||
);
|
||||
Some(serde_json::Value::Object(profile))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auth_tag_values(event: &serde_json::Value) -> Vec<&serde_json::Value> {
|
||||
event
|
||||
.get("tags")
|
||||
.and_then(|tags| tags.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|tag| {
|
||||
tag.as_array()
|
||||
.and_then(|values| values.first())
|
||||
.and_then(|value| value.as_str())
|
||||
== Some("auth")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auth_conditions_apply(auth_tag: &serde_json::Value, event: &serde_json::Value) -> bool {
|
||||
let Some(conditions) = auth_tag
|
||||
.as_array()
|
||||
.and_then(|values| values.get(2))
|
||||
.and_then(|value| value.as_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(kind) = event.get("kind").and_then(|value| value.as_u64()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(created_at) = event.get("created_at").and_then(|value| value.as_u64()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
conditions.split('&').all(|clause| {
|
||||
if let Some(value) = clause.strip_prefix("kind=") {
|
||||
value.parse::<u64>() == Ok(kind)
|
||||
} else if let Some(value) = clause.strip_prefix("created_at<") {
|
||||
value.parse::<u64>().is_ok_and(|bound| created_at < bound)
|
||||
} else if let Some(value) = clause.strip_prefix("created_at>") {
|
||||
value.parse::<u64>().is_ok_and(|bound| created_at > bound)
|
||||
} else {
|
||||
clause.is_empty()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'static str {
|
||||
let Some(agent_pubkey) = event
|
||||
.get("pubkey")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| PublicKey::parse(value).ok())
|
||||
else {
|
||||
return "invalid_agent_pubkey";
|
||||
};
|
||||
let auth_tags = auth_tag_values(event);
|
||||
let [auth_tag] = auth_tags.as_slice() else {
|
||||
return if auth_tags.is_empty() {
|
||||
"missing_auth"
|
||||
} else {
|
||||
"multiple_auth_tags"
|
||||
};
|
||||
};
|
||||
let Ok(auth_tag_json) = serde_json::to_string(auth_tag) else {
|
||||
return "invalid_auth";
|
||||
};
|
||||
match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &agent_pubkey) {
|
||||
Ok(owner) if owner.to_hex() != expected_owner => "owner_mismatch",
|
||||
Ok(_) if !auth_conditions_apply(auth_tag, event) => "condition_mismatch",
|
||||
Ok(_) => "verified",
|
||||
Err(_) => "invalid_auth",
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_scoped_profiles(
|
||||
events: &[serde_json::Value],
|
||||
pubkeys: &[String],
|
||||
owner: &str,
|
||||
effective_owner: &str,
|
||||
) -> Vec<serde_json::Value> {
|
||||
pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| {
|
||||
let event = events.iter().find(|event| {
|
||||
event.get("pubkey").and_then(|value| value.as_str()) == Some(pubkey.as_str())
|
||||
});
|
||||
let mut profile = event.map(profile_content).unwrap_or_default();
|
||||
let verification = if PublicKey::parse(pubkey).is_err() {
|
||||
"invalid_agent_pubkey"
|
||||
} else {
|
||||
event
|
||||
.map(|event| owner_verification(event, owner))
|
||||
.unwrap_or("missing_profile")
|
||||
};
|
||||
profile.insert("pubkey".to_string(), serde_json::json!(pubkey));
|
||||
profile.insert("verification".to_string(), serde_json::json!(verification));
|
||||
profile.insert(
|
||||
"owned_by_me".to_string(),
|
||||
serde_json::json!(verification == "verified" && owner == effective_owner),
|
||||
);
|
||||
if verification == "verified" {
|
||||
profile.insert("owner_pubkey".to_string(), serde_json::json!(owner));
|
||||
}
|
||||
serde_json::Value::Object(profile)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Search for users by display name. Owner-scoped searches resolve managed-agent records
|
||||
/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported.
|
||||
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 filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": query,
|
||||
"limit": 100
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
|
||||
// Parse and filter client-side for case-insensitive substring match
|
||||
// on display_name or name fields (NIP-50 may return broader matches).
|
||||
let events: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
|
||||
|
||||
let Some(arr) = events.as_array() else {
|
||||
println!("[]");
|
||||
return Ok(());
|
||||
let owner = resolve_owner(client, owner)?;
|
||||
let profiles = if let Some(owner) = owner {
|
||||
let pubkeys = owned_agent_pubkeys_by_name(client, &owner, query).await?;
|
||||
if pubkeys.is_empty() {
|
||||
println!("[]");
|
||||
return Ok(());
|
||||
}
|
||||
let valid_pubkeys: Vec<&String> = pubkeys
|
||||
.iter()
|
||||
.filter(|pubkey| PublicKey::parse(pubkey.as_str()).is_ok())
|
||||
.collect();
|
||||
let events = if valid_pubkeys.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": valid_pubkeys,
|
||||
"limit": valid_pubkeys.len(),
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?
|
||||
};
|
||||
owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client))
|
||||
} else {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [0],
|
||||
"search": query,
|
||||
"limit": 100
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
|
||||
name_search_profiles(&events, query)
|
||||
};
|
||||
|
||||
let lower_query = query.to_ascii_lowercase();
|
||||
let profiles: Vec<serde_json::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let content_str = event.get("content").and_then(|v| v.as_str())?;
|
||||
let content: serde_json::Value = serde_json::from_str(content_str).ok()?;
|
||||
let display_name = content
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !display_name.to_ascii_lowercase().contains(&lower_query)
|
||||
&& !name.to_ascii_lowercase().contains(&lower_query)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut profile = content;
|
||||
if let Some(obj) = profile.as_object_mut() {
|
||||
obj.insert(
|
||||
"pubkey".to_string(),
|
||||
serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
|
||||
);
|
||||
}
|
||||
Some(profile)
|
||||
})
|
||||
.collect();
|
||||
let output = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let compact: Vec<serde_json::Value> = profiles
|
||||
.iter()
|
||||
.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(),
|
||||
}))
|
||||
.map(|p| {
|
||||
let mut value = 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(),
|
||||
});
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for field in ["owner_pubkey", "owned_by_me", "verification"] {
|
||||
if let Some(field_value) = p.get(field) {
|
||||
obj.insert(field.to_string(), field_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
@@ -327,9 +536,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,
|
||||
@@ -362,9 +573,178 @@ pub async fn dispatch(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::presence_subject;
|
||||
use super::{
|
||||
owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification,
|
||||
presence_subject,
|
||||
};
|
||||
use nostr::Keys;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn owned_agent_lookup_matches_exact_name_case_insensitively() {
|
||||
let events = vec![
|
||||
json!({"content": r#"{"name":"Honey"}"#, "tags": [["d", "b"]]}),
|
||||
json!({"content": r#"{"name":"Honeybee"}"#, "tags": [["d", "c"]]}),
|
||||
json!({"content": r#"{"name":"honey"}"#, "tags": [["d", "a"]]}),
|
||||
];
|
||||
assert_eq!(
|
||||
owned_agent_pubkeys_from_events(&events, "Honey"),
|
||||
vec!["a", "b"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_agent_lookup_ignores_malformed_events() {
|
||||
let events = vec![
|
||||
json!({"content": "not json", "tags": [["d", "a"]]}),
|
||||
json!({"content": r#"{"name":"Honey"}"#, "tags": [["p", "b"]]}),
|
||||
];
|
||||
assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty());
|
||||
}
|
||||
|
||||
fn profile_event(agent_keys: &Keys, auth_tags: Vec<serde_json::Value>) -> serde_json::Value {
|
||||
json!({
|
||||
"pubkey": agent_keys.public_key().to_hex(),
|
||||
"kind": 0,
|
||||
"created_at": 100,
|
||||
"content": r#"{"display_name":"Renamed Honey"}"#,
|
||||
"tags": auth_tags,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_verification_requires_one_valid_auth_tag_for_requested_owner() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let foreign_owner_keys = Keys::generate();
|
||||
let valid_tag: serde_json::Value = serde_json::from_str(
|
||||
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let foreign_tag: serde_json::Value = serde_json::from_str(
|
||||
&buzz_sdk::nip_oa::compute_auth_tag(
|
||||
&foreign_owner_keys,
|
||||
&agent_keys.public_key(),
|
||||
"kind=9",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
owner_verification(
|
||||
&profile_event(&agent_keys, vec![valid_tag.clone()]),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
),
|
||||
"verified"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_verification(
|
||||
&profile_event(&agent_keys, vec![foreign_tag]),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
),
|
||||
"owner_mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_verification(
|
||||
&profile_event(&agent_keys, vec![]),
|
||||
&owner_keys.public_key().to_hex()
|
||||
),
|
||||
"missing_auth"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_verification(
|
||||
&profile_event(&agent_keys, vec![valid_tag.clone(), valid_tag]),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
),
|
||||
"multiple_auth_tags"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_verification(
|
||||
&profile_event(
|
||||
&agent_keys,
|
||||
vec![json!([
|
||||
"auth",
|
||||
owner_keys.public_key().to_hex(),
|
||||
"kind=9",
|
||||
"0".repeat(128)
|
||||
])],
|
||||
),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
),
|
||||
"invalid_auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_verification_requires_conditions_to_apply_to_profile_event() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let verification = |conditions: &str| {
|
||||
let auth_tag: serde_json::Value = serde_json::from_str(
|
||||
&buzz_sdk::nip_oa::compute_auth_tag(
|
||||
&owner_keys,
|
||||
&agent_keys.public_key(),
|
||||
conditions,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
owner_verification(
|
||||
&profile_event(&agent_keys, vec![auth_tag]),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(verification("kind=9"), "condition_mismatch");
|
||||
assert_eq!(verification("created_at<100"), "condition_mismatch");
|
||||
assert_eq!(verification("created_at>100"), "condition_mismatch");
|
||||
assert_eq!(
|
||||
verification("kind=0&created_at>99&created_at<101"),
|
||||
"verified"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_scoped_profiles_keep_drifted_and_missing_profiles_without_claiming_ownership() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let missing_keys = Keys::generate();
|
||||
let auth_tag: serde_json::Value = serde_json::from_str(
|
||||
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let events = vec![profile_event(&agent_keys, vec![auth_tag])];
|
||||
let pubkeys = vec![
|
||||
agent_keys.public_key().to_hex(),
|
||||
missing_keys.public_key().to_hex(),
|
||||
"malformed".to_string(),
|
||||
];
|
||||
|
||||
let profiles = owner_scoped_profiles(
|
||||
&events,
|
||||
&pubkeys,
|
||||
&owner_keys.public_key().to_hex(),
|
||||
&owner_keys.public_key().to_hex(),
|
||||
);
|
||||
|
||||
assert_eq!(profiles[0]["display_name"], "Renamed Honey");
|
||||
assert_eq!(profiles[0]["verification"], "verified");
|
||||
assert_eq!(profiles[0]["owned_by_me"], true);
|
||||
assert_eq!(
|
||||
profiles[0]["owner_pubkey"],
|
||||
owner_keys.public_key().to_hex()
|
||||
);
|
||||
assert_eq!(profiles[1]["verification"], "missing_profile");
|
||||
assert_eq!(profiles[1]["owned_by_me"], false);
|
||||
assert!(profiles[1].get("owner_pubkey").is_none());
|
||||
assert_eq!(profiles[2]["verification"], "invalid_agent_pubkey");
|
||||
assert_eq!(profiles[2]["owned_by_me"], false);
|
||||
assert!(profiles[2].get("owner_pubkey").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_subject_uses_p_tag() {
|
||||
let event = json!({"pubkey": "relay", "tags": [["p", "user"]]});
|
||||
|
||||
@@ -811,6 +811,9 @@ pub enum UsersCmd {
|
||||
/// Search by display name (case-insensitive substring match)
|
||||
#[arg(long = "name")]
|
||||
name: Option<String>,
|
||||
/// Scope an exact-name agent lookup to its owner (`me`, hex, or npub)
|
||||
#[arg(long = "owner", requires = "name")]
|
||||
owner: Option<String>,
|
||||
},
|
||||
/// Update the current identity's profile
|
||||
#[command(name = "set-profile")]
|
||||
|
||||
@@ -84,6 +84,9 @@ run_unit_tests() {
|
||||
run_test_step "buzz-auth unit tests" \
|
||||
cargo test -p buzz-auth --lib -- --nocapture
|
||||
|
||||
run_test_step "buzz-cli tests" \
|
||||
cargo test -p buzz-cli -- --nocapture
|
||||
|
||||
# buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator
|
||||
# invariant (exactly the consolidated 0001; cutover/backfill stays an operator
|
||||
# script, not startup state) and the tenant-scoping lints. The Postgres-backed
|
||||
|
||||
Reference in New Issue
Block a user