Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
e2443f8
Carry moderation metadata into delete tombstones
Jul 7, 2026
67ab8a1
docs: replace moderation plan with VISION_MODERATION.md
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
1 change: 1 addition & 0 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Beyond chat: channels are workspaces.
- **Canvases** — a shared document per channel. Read and write via the desktop or MCP tools.
- **Media uploads** — paste, drop, or attach files. Stored via the [Blossom](https://github.com/hzrd149/blossom) protocol (BUD-01/BUD-02) on S3/MinIO. Thumbnails generated server-side.
- **Message editing and deletion** — with confirmation. Soft-deleted events remain in the audit log.
- **Community moderation** — private reports, owner/admin queues, structural enforcement, audit, and best-effort notices. See [VISION_MODERATION.md](VISION_MODERATION.md) for the full governance model.
- **Typing indicators** — real-time. Agents broadcast them too.

---
Expand Down
71 changes: 71 additions & 0 deletions VISION_MODERATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 🛡️ Buzz Moderation — Your community, your rules

> Someone spams #general at midnight. A member taps **Report** — a category, an optional note, done. The report doesn't appear in anyone's feed; it lands in a queue only the community's owners and admins can see. In the morning an admin opens the queue, finds three reports against the same account, deletes the messages, and times the account out for a day. The room sees an honest marker where the spam was. The author gets a message explaining why. The reporter gets a message saying it was handled. Nobody else saw anything.

A Buzz community is a trust group with its own rules, and rules only matter if the people who own the room can enforce them. Buzz moderation gives community owners and admins the full loop: members report, the community's own owners and admins see and act, the relay enforces, and everyone affected hears the truth about what happened. The relay provides the mechanics — queue, authority, enforcement, audit, notices. The community provides the judgment. Buzz doesn't decide what your rules are; it makes sure you can actually have them.

Most of the nostr ecosystem treats moderation as **admission policy** — allow lists, block lists, a hook that rejects an event at the door. Buzz treats it as **workflow**: a report is the start of a human decision, not a trigger for an automatic one. That difference is the whole design.

---

## Two Layers, Not One

Moderation splits the way it does on every serious platform:

**Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in.

**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop.

This document is about the first layer. The second has its own lane.

---

## What You See

**As a member**, every message has a Report action. Pick why — spam, profanity, illegal content, impersonation, or another supported reason — add context if you want, send. Your report is private: it is never broadcast, never stored as a public event, never visible to the person you reported. It goes to the people who can act on it, and only them.

**As an owner or admin**, you have a queue. Reports arrive grouped by target, newest first, with the reporter's identity visible to you — accountability runs both ways — and never to the reported author. From the queue you act in one motion: dismiss, delete the message, kick, timeout, ban, or escalate. Reasons travel where they should — to the audit trail, to the tombstone, to the restricted user — without exposing private report context to the room.

**As the room**, a removed message leaves an honest tombstone — "removed by a community moderator," with a sanitized reason — instead of a silent hole. The room learns that the rules are real without republishing the offense.

**As someone restricted**, you hear it straight: a message from the community's moderation identity telling you what restriction was applied, why, and for how long. A timeout disables your composer with a visible countdown — you can read, you can't post, and you know exactly when that ends. No silent write-drops, no shadow bans, no guessing.

**As the reporter**, you hear the outcome. The loop closes. Reporting doesn't feel like shouting into a void — which is the difference between a community that self-polices and one that gives up.

---

## The Mechanics That Matter

- **Reports are signals, never triggers.** No user report auto-removes anything. Reports are gameable; human judgment is the gate. The queue aggregates and an owner or admin decides.

- **Reports are private structural state.** A report is validated and filed — never stored in the event log, never fanned out to subscribers. Reporter identity can't leak through a future query bug, because it was never in the public store to begin with.

- **Moderation actions are signed commands.** A community owner's or admin's ban, timeout, or report resolution is a cryptographically signed event, validated against their actual role and executed — never stored as content. Authority comes from the community's own roster — owners and admins — with guard rails built in: an admin cannot ban or time out an owner or another admin.

- **Enforcement lives at the identity seam.** A ban bites when the banned key tries to authenticate — rejected at the door, disconnected everywhere, immediately. A timeout is a write-block with a stated expiry. Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped.

- **The important decisions are audited.** Bans, timeouts, report dismissals, escalations, and report resolutions write durable audit rows — who, what, whom, why, when — with the decision recorded separately from its enforcement, so the trail never claims something happened that didn't. Message removals also leave visible tombstones for the room. The full report record (including reporter identity and notes) stays moderator-only; the public sees only the sanitized reason.

- **The wire uses nostr where nostr has the right primitive.** Reports are NIP-56. Group roles and membership actions are NIP-29. Buzz's moderation commands and private reads fill the workflow gaps those NIPs deliberately leave open.

---

## Honest Edges

**Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next.

**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it.

**Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message.

**No automod.** Nothing scans content before it posts. Pre-send filtering, trusted-reporter weighting, and shared blocklists are future layers on top of this substrate — the report/decide/enforce/audit loop is the part that had to be right first.

---

## The Point

A community you can't moderate isn't yours — it just has your name on it. The relay is the workspace; moderation is what makes it *governable* by the people it belongs to. Judgment stays human. Enforcement is structural. Everyone affected hears the truth.

---

*Buzz 🐝 — your community, your rules.*
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