Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d2b8e3d
feat(moderation): Phase 1 contract scaffold — schema, kinds, module s…
Jul 7, 2026
03c70ea
test(db): derive expected migration versions from MIGRATOR
Jul 7, 2026
48461d8
fix(moderation): contract patch — DB-enforced invariants + kind registry
Jul 7, 2026
da925b9
ingest: pin 9040-9044 as global-only per contract
Jul 7, 2026
378165d
moderation: align in-tree 9044 contract docs with pinned tag vocabulary
Jul 7, 2026
b1b26a9
feat(db): implement community moderation persistence (#1592)
tlongwell-block Jul 7, 2026
f4d4c5b
moderation(L5): relay-signed moderation notice DMs (#1590)
tlongwell-block Jul 7, 2026
8a2fbdd
moderation(L4): enforce bans/timeouts at the auth, ingest, and connec…
Jul 7, 2026
60cedbe
moderation(L4): pair live-ban disconnect with cross-pod fan-out on Ap…
Jul 7, 2026
f4225f7
Merge L4: bans + timeouts enforcement at auth seam with cluster-wide …
Jul 7, 2026
324b4d1
moderation(L6): command handler (9040-9044) + mod-queue read endpoints
Jul 7, 2026
275c928
moderation(L6): fix resolve-report audit races (Eva review #1599)
Jul 7, 2026
ba4a0db
Merge L6: moderation command handlers (9040-9044) + queue read endpoi…
Jul 7, 2026
23e8ebb
Phase-1 community moderation authorization seam (L2).
tlongwell-block Jul 7, 2026
3aff792
Merge L2: authorize_moderation_action capability helper (#1604)
Jul 7, 2026
88c15da
moderation(L6): verify moderation reads against full request URL incl…
Jul 7, 2026
831b40e
Merge L6 fix: verify moderation reads against full request URL incl. …
Jul 7, 2026
0904c02
Route moderation ingest events
Jul 7, 2026
51b833d
Merge L3: NIP-56 report ingest + moderation command routing (#1605)
Jul 7, 2026
67ee76d
moderation: buzz-cli moderation command group + SDK 9040–9044 builders
Jul 7, 2026
e295384
moderation: SDK builders enforce exact-64-hex for p/report tags
Jul 7, 2026
2081c85
Merge L7: buzz moderation CLI + SDK builders (#1591)
Jul 7, 2026
a4be017
Merge remote-tracking branch 'origin/main' into eva/community-moderation
Jul 7, 2026
ec34ca3
schema: fold 0006_moderation into schema.sql
Jul 7, 2026
9584ba7
relay(nip11): advertise NIP-56 in supported_nips
Jul 7, 2026
942a214
feat(desktop): moderation data layer — shared/api + feature hooks
Jul 7, 2026
05749d9
feat(desktop): moderation member surface — report modal, tombstone, t…
Jul 7, 2026
e2443f8
Carry moderation metadata into delete tombstones
Jul 7, 2026
a310d88
Wire the composer timeout chip on the member surface
Jul 7, 2026
c1c81d0
Merge remote-tracking branch 'origin/eva/community-moderation' into e…
Jul 8, 2026
e2cd320
Moderation U1: disable composer in a moderation DM (member surface) (…
tlongwell-block Jul 8, 2026
49141b0
desktop(moderation): U2 admin surface — queue, audit, member + per-me…
tlongwell-block Jul 8, 2026
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
38 changes: 33 additions & 5 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use buzz_sdk::{DiffMeta, ThreadRef, VoteDirection};
use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;

Expand Down Expand Up @@ -551,15 +551,29 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re
Ok(())
}

pub async fn cmd_delete_message(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
pub async fn cmd_delete_message(
client: &BuzzClient,
event_id: &str,
action_id: Option<Uuid>,
reason_code: Option<&str>,
public_reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(event_id)?;

// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;

let builder = buzz_sdk::build_delete_message(channel_uuid, target_eid)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;
let builder = buzz_sdk::build_delete_message_with_options(
channel_uuid,
target_eid,
DeleteMessageOptions {
action_id,
reason_code,
public_reason,
},
)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;

let event = client.sign_event(builder)?;

Expand Down Expand Up @@ -684,7 +698,21 @@ pub async fn dispatch(
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await,
MessagesCmd::Delete {
event,
action_id,
reason_code,
public_reason,
} => {
cmd_delete_message(
client,
&event,
action_id,
reason_code.as_deref(),
public_reason.as_deref(),
)
.await
}
MessagesCmd::Get {
channel,
limit,
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,
}
}
Loading
Loading