diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6e5eab1aa5..b56ca79e2b 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -245,6 +245,20 @@ impl BuzzClient { self.handle_response(resp).await } + /// GET an authed relay endpoint (NIP-98), returning the raw JSON body. + /// + /// `path` is a root-relative path incl. any query string, e.g. + /// `/moderation/reports?status=open&limit=20`. Used by the moderation + /// read commands, which read structured queue/audit rows rather than + /// stored events. + pub async fn get_authed(&self, path: &str) -> Result { + let url = format!("{}{path}", self.relay_url); + let auth = sign_nip98(&self.keys, "GET", &url, None)?; + let req = self.http.get(&url).header("Authorization", &auth); + let resp = self.with_auth_tag(req).send().await?; + self.handle_response(resp).await + } + /// Submit a signed Nostr event via POST /events. pub async fn submit_event(&self, event: nostr::Event) -> Result { let url = format!("{}/events", self.relay_url); diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index cc62f9f193..9a15957c51 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod feed; pub mod issues; pub mod mem; pub mod messages; +pub mod moderation; pub mod notes; pub mod pack; pub mod patches; diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs new file mode 100644 index 0000000000..c53aecaf85 --- /dev/null +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -0,0 +1,165 @@ +//! `buzz moderation` — community moderation queue, enforcement, and audit. +//! +//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed +//! command events (kinds 9040–9044) submitted via `POST /events`, mirroring +//! the NIP-43 relay-admin 9030-series: the relay validates, authorizes +//! (owner/admin only), and executes them directly — they are never stored. +//! +//! Reads (`reports`/`restricted`/`audit`) hit dedicated mod-only, +//! NIP-98-authed relay endpoints under `/moderation/*`, because reports and +//! audit rows are structured queue rows, not public nostr events — serving +//! them over a REQ filter would mean synthesizing fake events and threading a +//! privileged authz check into the public read path. +//! +//! The community (tenant) is selected by the relay host — moderation commands +//! carry no channel scope. + +use nostr::Timestamp; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::validate_hex64; +use crate::{ModerationCmd, OutputFormat}; + +/// Resolve `--expires-in ` / `--expires-at ` into an absolute +/// unix-seconds expiry. At most one may be set (enforced by clap). +fn resolve_expiry(expires_in: Option, expires_at: Option) -> Option { + match (expires_in, expires_at) { + (Some(secs), _) => Some(Timestamp::now().as_secs() + secs), + (None, Some(ts)) => Some(ts), + (None, None) => None, + } +} + +async fn cmd_ban( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let expiry = resolve_expiry(expires_in, expires_at); + 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?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(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?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_timeout( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(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) + .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(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?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_resolve( + client: &BuzzClient, + report: &str, + status: &str, + action: &str, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(report)?; + let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason) + .map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_reports( + client: &BuzzClient, + status: Option<&str>, + limit: i64, +) -> Result<(), CliError> { + let mut path = format!("/moderation/reports?limit={limit}"); + if let Some(s) = status { + path.push_str(&format!("&status={s}")); + } + let resp = client.get_authed(&path).await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> { + let resp = client.get_authed("/moderation/restricted").await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> { + let resp = client + .get_authed(&format!("/moderation/audit?limit={limit}")) + .await?; + println!("{resp}"); + Ok(()) +} + +pub async fn dispatch( + cmd: ModerationCmd, + client: &BuzzClient, + _format: &OutputFormat, +) -> Result<(), CliError> { + match cmd { + ModerationCmd::Reports { status, limit } => { + cmd_reports(client, status.as_deref(), limit).await + } + ModerationCmd::Resolve { + report, + status, + action, + reason, + } => cmd_resolve(client, &report, &status, &action, reason.as_deref()).await, + ModerationCmd::Ban { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_ban(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Unban { pubkey } => cmd_unban(client, &pubkey).await, + ModerationCmd::Timeout { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await, + ModerationCmd::Restricted => cmd_restricted(client).await, + ModerationCmd::Audit { limit } => cmd_audit(client, limit).await, + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 191cc73d9b..0b9061937c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -213,6 +213,9 @@ enum Cmd { /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), + /// Community moderation — reports queue, bans, timeouts, audit trail + #[command(subcommand)] + Moderation(ModerationCmd), } #[derive(Subcommand)] @@ -1408,6 +1411,102 @@ pub enum PackCmd { }, } +/// Community moderation commands. +/// +/// The community (tenant) is selected by the relay host in `--relay` / +/// `BUZZ_RELAY_URL` — moderation commands are community-global and carry no +/// channel scope. The signing key must be a community owner/admin; the relay +/// authorizes every command. +#[derive(Subcommand)] +pub enum ModerationCmd { + /// List reports in the moderation queue (newest first) + #[command( + after_help = "Examples:\n buzz moderation reports\n buzz moderation reports --status open --limit 20" + )] + Reports { + /// Filter by status: open | resolved | dismissed | escalated (default: all) + #[arg(long)] + status: Option, + /// Maximum number of reports to return + #[arg(long, default_value_t = 50)] + limit: i64, + }, + /// Resolve or dismiss a report (kind 9044) + #[command( + after_help = "Examples:\n buzz moderation resolve --report --status dismissed --action dismiss\n buzz moderation resolve --report --status resolved --action ban --reason \"rule 3\"" + )] + Resolve { + /// Hex event id of the kind:1984 report being resolved + #[arg(long)] + report: String, + /// Resolution status: resolved | dismissed + #[arg(long)] + status: String, + /// Action taken: delete | kick | ban | timeout | dismiss | escalate + #[arg(long)] + action: String, + /// Optional reason — relayed to the reporter, so keep it tombstone-safe + #[arg(long)] + reason: Option, + }, + /// 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\"" + )] + Ban { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + /// Ban duration in seconds from now (omit for a permanent ban) + #[arg(long, conflicts_with = "expires_at")] + expires_in: Option, + /// Absolute ban expiry as a unix timestamp (seconds) + #[arg(long)] + expires_at: Option, + /// Optional private ban reason (audit only) + #[arg(long)] + reason: Option, + }, + /// Lift a member's ban (kind 9041) + Unban { + /// Target member pubkey (hex) + #[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\"" + )] + Timeout { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + /// Timeout duration in seconds from now + #[arg(long, conflicts_with = "expires_at")] + expires_in: Option, + /// Absolute timeout expiry as a unix timestamp (seconds) + #[arg(long)] + expires_at: Option, + /// Optional private timeout reason (audit only) + #[arg(long)] + reason: Option, + }, + /// Clear a member's timeout early (kind 9043) + Untimeout { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + }, + /// List currently-restricted members (active ban or timeout) + Restricted, + /// Read the moderation audit trail (newest first) + Audit { + /// Maximum number of audit rows to return + #[arg(long, default_value_t = 50)] + limit: i64, + }, +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1463,6 +1562,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, + Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), } } @@ -1489,6 +1589,7 @@ mod tests { "issues", "mem", "messages", + "moderation", "notes", "pack", "patches", @@ -1620,6 +1721,19 @@ mod tests { ); assert_eq!(names(&cmd, "upload"), vec!["file"]); assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]); + assert_eq!( + names(&cmd, "moderation"), + vec![ + "audit", + "ban", + "reports", + "resolve", + "restricted", + "timeout", + "unban", + "untimeout" + ] + ); } #[test] diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 5cb2bf118c..f3d365d431 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -9,7 +9,9 @@ use buzz_core::{ KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_GIT_STATUS_OPEN, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1511,6 +1513,111 @@ pub fn build_presence_update(status: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags)) } +// --------------------------------------------------------------------------- +// Community moderation commands (kinds 9040–9044). +// +// These mirror the NIP-43 relay-admin 9030-series: mod-signed command events +// that the relay validates + executes directly and never stores. The tenant +// (community) is bound by the connection host, so no `h` tag is carried — a +// stray `h` would be rejected as channel-scoping a global-only command. The +// tag vocabulary below is pinned by `moderation_commands.rs` (relay and CLI +// must agree). +// --------------------------------------------------------------------------- + +/// Build a community ban command (kind 9040). +/// +/// `expires_at`: `None` ⇒ permanent; `Some(unix_secs)` ⇒ ban lifts at that time. +pub fn build_moderation_ban( + target_pubkey: &str, + expires_at: Option, + reason: Option<&str>, +) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let mut tags = vec![tag(&["p", &target_pubkey])?]; + if let Some(exp) = expires_at { + tags.push(tag(&["expiration", &exp.to_string()])?); + } + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_BAN as u16), "").tags(tags)) +} + +/// Build a community unban command (kind 9041). +pub fn build_moderation_unban(target_pubkey: &str) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNBAN as u16), "").tags(tags)) +} + +/// Build a community timeout (write-block) command (kind 9042). +/// +/// `expires_at` (required) is the unix-seconds timestamp the timeout lifts at. +pub fn build_moderation_timeout( + target_pubkey: &str, + expires_at: u64, + reason: Option<&str>, +) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let mut tags = vec![ + tag(&["p", &target_pubkey])?, + tag(&["expiration", &expires_at.to_string()])?, + ]; + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_TIMEOUT as u16), "").tags(tags)) +} + +/// Build a community untimeout command (kind 9043). +pub fn build_moderation_untimeout(target_pubkey: &str) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNTIMEOUT as u16), "").tags(tags)) +} + +/// Build a resolve-report command (kind 9044). +/// +/// `report_event_id`: hex event id of the kind:1984 report being resolved. +/// `status`: `resolved` | `dismissed`. `action`: `delete` | `kick` | `ban` | +/// `timeout` | `dismiss` | `escalate` (`dismiss` pairs with `dismissed`, +/// everything else with `resolved` — the relay enforces the pairing). +/// `reason`: optional; audited into `public_reason` and relayed in the +/// reporter notice DM, so it must be safe for the reporter to read. +pub fn build_moderation_resolve_report( + report_event_id: &str, + status: &str, + action: &str, + reason: Option<&str>, +) -> Result { + let report_event_id = check_hex_exact(report_event_id, 64, "report_event_id")?; + match status { + "resolved" | "dismissed" => {} + _ => { + return Err(SdkError::InvalidInput(format!( + "status must be resolved or dismissed (got: {status})" + ))) + } + } + match action { + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" => {} + _ => { + return Err(SdkError::InvalidInput(format!( + "action must be delete, kick, ban, timeout, dismiss, or escalate (got: {action})" + ))) + } + } + let mut tags = vec![ + tag(&["report", &report_event_id])?, + tag(&["status", status])?, + tag(&["action", action])?, + ]; + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -3134,4 +3241,122 @@ mod tests { let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err(); assert!(matches!(err, SdkError::InvalidInput(_))); } + + // --- community moderation commands (9040–9044) ------------------------ + + #[test] + fn moderation_ban_permanent() { + let pk = "a".repeat(64); + let ev = sign(build_moderation_ban(&pk, None, None).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(tag_values(&ev, "expiration").is_empty()); + assert!(tag_values(&ev, "reason").is_empty()); + } + + #[test] + fn moderation_ban_temporary_with_reason() { + let pk = "b".repeat(64); + let ev = sign(build_moderation_ban(&pk, Some(1783500000), Some("spam")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(has_tag(&ev, "expiration", "1783500000")); + assert!(has_tag(&ev, "reason", "spam")); + } + + #[test] + fn moderation_ban_lowercases_pubkey() { + let ev = sign(build_moderation_ban(&"A".repeat(64), None, None).unwrap()); + assert!(has_tag(&ev, "p", &"a".repeat(64))); + } + + #[test] + fn moderation_ban_rejects_short_pubkey() { + let err = build_moderation_ban("abc", None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_ban_rejects_overlong_pubkey() { + // Relay `extract_p_tag_bytes` requires exactly 64 hex; the SDK must + // reject 65+ hex here rather than sign a `p` tag the relay drops. + let err = build_moderation_ban(&"a".repeat(65), None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_unban_shape() { + let pk = "c".repeat(64); + let ev = sign(build_moderation_unban(&pk).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNBAN as u16); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn moderation_timeout_shape() { + let pk = "d".repeat(64); + let ev = sign(build_moderation_timeout(&pk, 1783500000, Some("cool off")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_TIMEOUT as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(has_tag(&ev, "expiration", "1783500000")); + assert!(has_tag(&ev, "reason", "cool off")); + } + + #[test] + fn moderation_untimeout_shape() { + let pk = "e".repeat(64); + let ev = sign(build_moderation_untimeout(&pk).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNTIMEOUT as u16); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn moderation_resolve_shape() { + let rid = event_id().to_hex(); + let ev = + sign(build_moderation_resolve_report(&rid, "resolved", "ban", Some("rule 3")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_RESOLVE_REPORT as u16); + assert!(has_tag(&ev, "report", &rid)); + assert!(has_tag(&ev, "status", "resolved")); + assert!(has_tag(&ev, "action", "ban")); + assert!(has_tag(&ev, "reason", "rule 3")); + } + + #[test] + fn moderation_resolve_dismiss_no_reason() { + let rid = event_id().to_hex(); + let ev = sign(build_moderation_resolve_report(&rid, "dismissed", "dismiss", None).unwrap()); + assert!(has_tag(&ev, "status", "dismissed")); + assert!(has_tag(&ev, "action", "dismiss")); + assert!(tag_values(&ev, "reason").is_empty()); + } + + #[test] + fn moderation_resolve_rejects_bad_status() { + let rid = event_id().to_hex(); + let err = build_moderation_resolve_report(&rid, "escalated", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_bad_action() { + let rid = event_id().to_hex(); + let err = build_moderation_resolve_report(&rid, "resolved", "nuke", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_short_report_id() { + let err = build_moderation_resolve_report("abc", "resolved", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_overlong_report_id() { + // Relay `extract_report_tag` requires exactly 64 hex; the SDK must + // reject 65+ hex here rather than sign a `report` tag the relay drops. + let err = + build_moderation_resolve_report(&"a".repeat(65), "resolved", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } }