From 2b21d2b95a9fe08e70f809a1866417eebd803bae Mon Sep 17 00:00:00 2001 From: Nawazish Khan <175596916+nawazish2@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:29:42 +0530 Subject: [PATCH] fix(cli): accept npub on --pubkey identity flags Desktop and chat show npubs, but flags like channels add-member only took hex and failed with a vague error. --mention already accepted both. Add normalize_pubkey (hex or npub -> lowercase hex) and use it for user-identity commands: channels, users, dms, moderation, agents. Git-protocol fields stay hex-only. Signed-off-by: Nawazish Khan <175596916+nawazish2@users.noreply.github.com> --- crates/buzz-cli/src/commands/agents.rs | 7 +-- crates/buzz-cli/src/commands/channels.rs | 10 ++-- crates/buzz-cli/src/commands/dms.rs | 13 ++--- crates/buzz-cli/src/commands/moderation.rs | 18 +++---- crates/buzz-cli/src/commands/users.rs | 17 ++++--- crates/buzz-cli/src/lib.rs | 30 ++++++------ crates/buzz-cli/src/validate.rs | 56 ++++++++++++++++++++++ 7 files changed, 104 insertions(+), 47 deletions(-) diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2f..6b284cabf37 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::{read_or_stdin, validate_hex64}; +use crate::validate::{normalize_pubkey, read_or_stdin}; use crate::{AgentsCmd, RespondToArg}; pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { @@ -91,7 +91,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli replaced_by, content, } => { - validate_hex64(&target_pubkey)?; + let target_pubkey = normalize_pubkey(&target_pubkey)?; + let replaced_by = replaced_by.as_deref().map(normalize_pubkey).transpose()?; let signer_hex = client.keys().public_key().to_hex(); let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; let builder = build_archive_identity_request( @@ -122,7 +123,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli reason, content, } => { - validate_hex64(&target_pubkey)?; + let target_pubkey = normalize_pubkey(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; let builder = build_unarchive_identity_request( diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e02..cf6d7e37815 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -11,7 +11,7 @@ use crate::client::{ use crate::commands::agents::fetch_archived_snapshot; use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{normalize_pubkey, parse_uuid, read_or_stdin, validate_uuid}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -959,7 +959,7 @@ pub async fn cmd_add_channel_member( pubkey: &str, role: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; let typed_role = match role { @@ -975,7 +975,7 @@ pub async fn cmd_add_channel_member( ))) } }; - let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role) + let builder = buzz_sdk::build_add_member(channel_uuid, &pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; let event = client.sign_event(builder)?; @@ -989,10 +989,10 @@ pub async fn cmd_remove_channel_member( channel_id: &str, pubkey: &str, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; - let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey) + let builder = buzz_sdk::build_remove_member(channel_uuid, &pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; let event = client.sign_event(builder)?; diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e4118270..81b4d905ffc 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::{parse_uuid, sdk_err, validate_hex64}; +use crate::validate::{normalize_pubkey, parse_uuid, sdk_err}; /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { @@ -52,9 +52,10 @@ pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into())); } - for pk in pubkeys { - validate_hex64(pk)?; - } + let pubkeys: Vec = pubkeys + .iter() + .map(|pk| normalize_pubkey(pk)) + .collect::, _>>()?; let dm_id = Uuid::new_v4().to_string(); let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect(); @@ -115,9 +116,9 @@ pub async fn cmd_add_dm_member( pubkey: &str, ) -> Result<(), CliError> { let channel_uuid = parse_uuid(channel_id)?; - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; - let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?; + let builder = buzz_sdk::build_dm_add_member(channel_uuid, &pubkey).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs index c53aecaf852..6c24fe67b57 100644 --- a/crates/buzz-cli/src/commands/moderation.rs +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -18,7 +18,7 @@ use nostr::Timestamp; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{normalize_pubkey, validate_hex64}; use crate::{ModerationCmd, OutputFormat}; /// Resolve `--expires-in ` / `--expires-at ` into an absolute @@ -38,9 +38,9 @@ async fn cmd_ban( expires_at: Option, reason: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let expiry = resolve_expiry(expires_in, expires_at); - let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) + let builder = buzz_sdk::build_moderation_ban(&pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -49,8 +49,8 @@ async fn cmd_ban( } async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { - validate_hex64(pubkey)?; - let builder = buzz_sdk::build_moderation_unban(pubkey) + let pubkey = normalize_pubkey(pubkey)?; + let builder = buzz_sdk::build_moderation_unban(&pubkey) .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -65,10 +65,10 @@ async fn cmd_timeout( expires_at: Option, reason: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let expiry = resolve_expiry(expires_in, expires_at) .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; - let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) + let builder = buzz_sdk::build_moderation_timeout(&pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -77,8 +77,8 @@ async fn cmd_timeout( } async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { - validate_hex64(pubkey)?; - let builder = buzz_sdk::build_moderation_untimeout(pubkey) + let pubkey = normalize_pubkey(pubkey)?; + let builder = buzz_sdk::build_moderation_untimeout(&pubkey) .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 7c15d285a0d..d72c8e8b2ff 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -3,7 +3,7 @@ use nostr::PublicKey; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::normalize_pubkey; // TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder @@ -32,12 +32,13 @@ pub async fn cmd_get_users( return Err(CliError::Usage("--owner requires --name".into())); } - for pk in pubkeys { - validate_hex64(pk)?; - } if pubkeys.len() > 200 { return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); } + let pubkeys: Vec = pubkeys + .iter() + .map(|pk| normalize_pubkey(pk)) + .collect::, _>>()?; let my_pk = client.keys().public_key().to_hex(); let authors: Vec<&str> = if pubkeys.is_empty() { @@ -454,14 +455,12 @@ async fn fetch_current_profile( /// Get presence status for users — query kind:40902 presence snapshot events. pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result<(), CliError> { - let pubkeys: Vec<&str> = pubkeys_csv + let pubkeys: Vec = pubkeys_csv .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) - .collect(); - for pk in &pubkeys { - validate_hex64(pk)?; - } + .map(normalize_pubkey) + .collect::, _>>()?; let filter = serde_json::json!({ "kinds": [40902], diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 8a8bb053b0f..37ee3ebccfd 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -311,12 +311,12 @@ buzz agents archive --reason retired\n \ buzz agents archive --reason bot-rebuilt --replaced-by " )] Archive { - /// Target identity pubkey (hex) + /// Target identity pubkey (hex or npub) target_pubkey: String, /// Machine-readable reason code, max 64 UTF-8 bytes #[arg(long)] reason: Option, - /// Rotation pointer pubkey (hex); must differ from the target + /// Rotation pointer pubkey (hex or npub); must differ from the target #[arg(long)] replaced_by: Option, /// Optional human-readable note (not parsed for authorization) @@ -327,7 +327,7 @@ buzz agents archive --reason bot-rebuilt --replaced-by " #[command(after_help = "Examples:\n \ buzz agents unarchive --reason returned")] Unarchive { - /// Target identity pubkey (hex) + /// Target identity pubkey (hex or npub) target_pubkey: String, /// Machine-readable reason code, max 64 UTF-8 bytes #[arg(long)] @@ -656,7 +656,7 @@ pub enum ChannelsCmd { /// Channel UUID #[arg(long)] channel: String, - /// Member pubkey (64-char hex) + /// Member pubkey (hex or npub) #[arg(long)] pubkey: String, /// Member role (owner, admin, member, guest, bot) @@ -669,7 +669,7 @@ pub enum ChannelsCmd { /// Channel UUID #[arg(long)] channel: String, - /// Member pubkey (64-char hex) + /// Member pubkey (hex or npub) #[arg(long)] pubkey: String, }, @@ -784,7 +784,7 @@ pub enum DmsCmd { }, /// Open a new direct message with one or more users Open { - /// User pubkey(s) to DM (64-char hex, 1-8) + /// User pubkey(s) to DM (hex or npub, 1-8) #[arg(long = "pubkey")] pubkeys: Vec, }, @@ -793,7 +793,7 @@ pub enum DmsCmd { /// DM conversation UUID #[arg(long)] channel: String, - /// User pubkey to add (64-char hex) + /// User pubkey to add (hex or npub) #[arg(long)] pubkey: String, }, @@ -809,7 +809,7 @@ pub enum DmsCmd { pub enum UsersCmd { /// Look up user profiles by pubkey or name Get { - /// User pubkey(s) to look up (64-char hex). Omit for your own profile + /// User pubkey(s) to look up (hex or npub). Omit for your own profile #[arg(long = "pubkey")] pubkeys: Vec, /// Search by display name (case-insensitive substring match) @@ -837,7 +837,7 @@ pub enum UsersCmd { }, /// Get presence status for users Presence { - /// Comma-separated pubkeys (64-char hex) + /// Comma-separated pubkeys (hex or npub) #[arg(long)] pubkeys: String, }, @@ -1832,10 +1832,10 @@ pub enum ModerationCmd { }, /// Ban a member from the community (kind 9040) #[command( - after_help = "Examples:\n buzz moderation ban --pubkey \n buzz moderation ban --pubkey --expires-in 604800 --reason \"repeated spam\"" + after_help = "Examples:\n buzz moderation ban --pubkey \n buzz moderation ban --pubkey --expires-in 604800 --reason \"repeated spam\"" )] Ban { - /// Target member pubkey (hex) + /// Target member pubkey (hex or npub) #[arg(long)] pubkey: String, /// Ban duration in seconds from now (omit for a permanent ban) @@ -1850,16 +1850,16 @@ pub enum ModerationCmd { }, /// Lift a member's ban (kind 9041) Unban { - /// Target member pubkey (hex) + /// Target member pubkey (hex or npub) #[arg(long)] pubkey: String, }, /// Time out a member — a write-block, not a disconnect (kind 9042) #[command( - after_help = "Examples:\n buzz moderation timeout --pubkey --expires-in 3600\n buzz moderation timeout --pubkey --expires-at 1783500000 --reason \"cool off\"" + after_help = "Examples:\n buzz moderation timeout --pubkey --expires-in 3600\n buzz moderation timeout --pubkey --expires-at 1783500000 --reason \"cool off\"" )] Timeout { - /// Target member pubkey (hex) + /// Target member pubkey (hex or npub) #[arg(long)] pubkey: String, /// Timeout duration in seconds from now @@ -1874,7 +1874,7 @@ pub enum ModerationCmd { }, /// Clear a member's timeout early (kind 9043) Untimeout { - /// Target member pubkey (hex) + /// Target member pubkey (hex or npub) #[arg(long)] pubkey: String, }, diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..86a78990e05 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -35,6 +35,18 @@ pub fn validate_hex64(s: &str) -> Result<(), CliError> { Ok(()) } +/// Accept a 64-char hex pubkey or bech32 `npub1…`, return lowercase hex. +/// +/// Use this for user-identity flags (`--pubkey` on channels, DMs, users, +/// moderation, agents). Git-protocol fields that only ever carry hex from CLI +/// output can keep [`validate_hex64`]. +pub fn normalize_pubkey(s: &str) -> Result { + let s = s.trim(); + nostr::PublicKey::parse(s) + .map(|pk| pk.to_hex()) + .map_err(|_| CliError::Usage(format!("must be a 64-character hex pubkey or npub: {s}"))) +} + /// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. pub fn validate_repo_id(s: &str) -> Result<(), CliError> { if s.is_empty() || s.len() > 64 { @@ -256,6 +268,50 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } + // --- normalize_pubkey --- + // Same test vectors as buzz-sdk mentions (valid secp256k1 keys). + + const TEST_HEX: &str = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; + const TEST_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + #[test] + fn normalize_pubkey_hex() { + assert_eq!(normalize_pubkey(TEST_HEX).unwrap(), TEST_HEX); + } + + #[test] + fn normalize_pubkey_hex_uppercase() { + assert_eq!( + normalize_pubkey(&TEST_HEX.to_uppercase()).unwrap(), + TEST_HEX + ); + } + + #[test] + fn normalize_pubkey_npub() { + assert_eq!(normalize_pubkey(TEST_NPUB).unwrap(), TEST_HEX); + } + + #[test] + fn normalize_pubkey_trims_whitespace() { + assert_eq!( + normalize_pubkey(&format!(" {TEST_NPUB} ")).unwrap(), + TEST_HEX + ); + } + + #[test] + fn normalize_pubkey_rejects_garbage() { + let err = normalize_pubkey("not-a-key").unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn normalize_pubkey_rejects_wrong_length_hex() { + let err = normalize_pubkey(&"ab".repeat(20)).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + // --- validate_content_size --- #[test]