Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -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 {
Expand All @@ -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)?;
Expand All @@ -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)?;
Expand Down
13 changes: 7 additions & 6 deletions crates/buzz-cli/src/commands/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>) -> Result<(), CliError> {
Expand Down Expand Up @@ -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<String> = pubkeys
.iter()
.map(|pk| normalize_pubkey(pk))
.collect::<Result<Vec<_>, _>>()?;
let dm_id = Uuid::new_v4().to_string();
let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect();

Expand Down Expand Up @@ -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?;
Expand Down
18 changes: 9 additions & 9 deletions crates/buzz-cli/src/commands/moderation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <secs>` / `--expires-at <unix>` into an absolute
Expand All @@ -38,9 +38,9 @@ async fn cmd_ban(
expires_at: Option<u64>,
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?;
Expand All @@ -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?;
Expand All @@ -65,10 +65,10 @@ async fn cmd_timeout(
expires_at: Option<u64>,
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?;
Expand All @@ -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?;
Expand Down
17 changes: 8 additions & 9 deletions crates/buzz-cli/src/commands/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<String> = pubkeys
.iter()
.map(|pk| normalize_pubkey(pk))
.collect::<Result<Vec<_>, _>>()?;

let my_pk = client.keys().public_key().to_hex();
let authors: Vec<&str> = if pubkeys.is_empty() {
Expand Down Expand Up @@ -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<String> = pubkeys_csv
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for pk in &pubkeys {
validate_hex64(pk)?;
}
.map(normalize_pubkey)
.collect::<Result<Vec<_>, _>>()?;

let filter = serde_json::json!({
"kinds": [40902],
Expand Down
30 changes: 15 additions & 15 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,12 @@ buzz agents archive <PUBKEY> --reason retired\n \
buzz agents archive <PUBKEY> --reason bot-rebuilt --replaced-by <NEW_PUBKEY>"
)]
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<String>,
/// 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<String>,
/// Optional human-readable note (not parsed for authorization)
Expand All @@ -327,7 +327,7 @@ buzz agents archive <PUBKEY> --reason bot-rebuilt --replaced-by <NEW_PUBKEY>"
#[command(after_help = "Examples:\n \
buzz agents unarchive <PUBKEY> --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)]
Expand Down Expand Up @@ -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)
Expand All @@ -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,
},
Expand Down Expand Up @@ -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<String>,
},
Expand All @@ -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,
},
Expand All @@ -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<String>,
/// Search by display name (case-insensitive substring match)
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -1832,10 +1832,10 @@ pub enum ModerationCmd {
},
/// Ban a member from the community (kind 9040)
#[command(
after_help = "Examples:\n buzz moderation ban --pubkey <HEX>\n buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason \"repeated spam\""
after_help = "Examples:\n buzz moderation ban --pubkey <HEX_OR_NPUB>\n buzz moderation ban --pubkey <HEX_OR_NPUB> --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)
Expand All @@ -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 <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --expires-at 1783500000 --reason \"cool off\""
after_help = "Examples:\n buzz moderation timeout --pubkey <HEX_OR_NPUB> --expires-in 3600\n buzz moderation timeout --pubkey <HEX_OR_NPUB> --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
Expand All @@ -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,
},
Expand Down
56 changes: 56 additions & 0 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CliError> {
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 {
Expand Down Expand Up @@ -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]
Expand Down