Skip to content
Merged
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
14 changes: 14 additions & 0 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CliError> {
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<String, CliError> {
let url = format!("{}/events", self.relay_url);
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
165 changes: 165 additions & 0 deletions crates/buzz-cli/src/commands/moderation.rs
Original file line number Diff line number Diff line change
@@ -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 <secs>` / `--expires-at <unix>` into an absolute
/// unix-seconds expiry. At most one may be set (enforced by clap).
fn resolve_expiry(expires_in: Option<u64>, expires_at: Option<u64>) -> Option<u64> {
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<u64>,
expires_at: Option<u64>,
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<u64>,
expires_at: Option<u64>,
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,
}
}
114 changes: 114 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,
/// 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 <REPORT_EVENT_ID> --status dismissed --action dismiss\n buzz moderation resolve --report <REPORT_EVENT_ID> --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<String>,
},
/// 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\""
)]
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<u64>,
/// Absolute ban expiry as a unix timestamp (seconds)
#[arg(long)]
expires_at: Option<u64>,
/// Optional private ban reason (audit only)
#[arg(long)]
reason: Option<String>,
},
/// 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 <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --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<u64>,
/// Absolute timeout expiry as a unix timestamp (seconds)
#[arg(long)]
expires_at: Option<u64>,
/// Optional private timeout reason (audit only)
#[arg(long)]
reason: Option<String>,
},
/// 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);

Expand Down Expand Up @@ -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"),
}
}
Expand All @@ -1489,6 +1589,7 @@ mod tests {
"issues",
"mem",
"messages",
"moderation",
"notes",
"pack",
"patches",
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading