diff --git a/VISION.md b/VISION.md index 9093b75dd..b27dbd4ee 100644 --- a/VISION.md +++ b/VISION.md @@ -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. --- diff --git a/VISION_MODERATION.md b/VISION_MODERATION.md new file mode 100644 index 000000000..45d3ab86f --- /dev/null +++ b/VISION_MODERATION.md @@ -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.* diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6e5eab1aa..b56ca79e2 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/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 714fcf5d6..ed510a981 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,4 +1,4 @@ -use buzz_sdk::{DiffMeta, ThreadRef, VoteDirection}; +use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; @@ -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, + 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)?; @@ -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, diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index cc62f9f19..9a15957c5 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 000000000..c53aecaf8 --- /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 191cc73d9..7ee195828 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -7,6 +7,7 @@ use clap::{Parser, Subcommand}; use client::BuzzClient; use error::CliError; use nostr::Keys; +use uuid::Uuid; /// Run the Buzz CLI from raw arguments (including `argv[0]`). /// @@ -213,6 +214,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)] @@ -294,6 +298,15 @@ pub enum MessagesCmd { /// Event ID to delete (64-char hex) #[arg(long)] event: String, + /// Optional moderation audit action UUID for the public tombstone + #[arg(long)] + action_id: Option, + /// Optional machine-readable public reason code for the tombstone + #[arg(long)] + reason_code: Option, + /// Optional human-readable public reason for the tombstone + #[arg(long)] + public_reason: Option, }, /// Retrieve messages from a channel #[command( @@ -1408,6 +1421,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 +1572,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 +1599,7 @@ mod tests { "issues", "mem", "messages", + "moderation", "notes", "pack", "patches", @@ -1620,6 +1731,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-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ae9e7ef13..0bd1a9fbd 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -173,6 +173,14 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +// NIP-56 reporting +/// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). +/// +/// Accepted at ingest, persisted to the tenant-scoped `moderation_reports` +/// queue, and never fanned out publicly. Reports are signals, not triggers: +/// the relay never auto-actions on them (NIP-56). +pub const KIND_REPORT: u32 = 1984; + // NIP-29 group admin events /// NIP-29: Add a user to a group. pub const KIND_NIP29_PUT_USER: u32 = 9000; @@ -193,6 +201,40 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021; /// NIP-29: Request to leave a group. pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022; +// Buzz community moderation commands (mod-signed, processed like 9030-series: +// validated + executed directly, never stored as regular events; every +// accepted command writes a `moderation_actions` audit row). +/// Moderation: ban a pubkey from the community (`p` tag target, optional +/// `expiration` + `reason` tags). +pub const KIND_MODERATION_BAN: u32 = 9040; +/// Moderation: lift a community ban (`p` tag target). +pub const KIND_MODERATION_UNBAN: u32 = 9041; +/// Moderation: timeout (write-block) a pubkey until an `expiration` tag +/// timestamp (`p` tag target, optional `reason`). +pub const KIND_MODERATION_TIMEOUT: u32 = 9042; +/// Moderation: clear a timeout early (`p` tag target). +pub const KIND_MODERATION_UNTIMEOUT: u32 = 9043; +/// Moderation: resolve a report (`report` tag = report event id hex, +/// `status` tag = resolved|dismissed, `action` tag = +/// delete|kick|ban|timeout|dismiss|escalate — see +/// `handlers/moderation_commands.rs` for the pinned vocabulary). +pub const KIND_MODERATION_RESOLVE_REPORT: u32 = 9044; + +/// Returns `true` for community moderation command kinds (9040–9044). +/// +/// The canonical route check — use this instead of scattering +/// `9040..=9044` matches across ingest/dispatch. +pub const fn is_moderation_command_kind(kind: u32) -> bool { + matches!( + kind, + KIND_MODERATION_BAN + | KIND_MODERATION_UNBAN + | KIND_MODERATION_TIMEOUT + | KIND_MODERATION_UNTIMEOUT + | KIND_MODERATION_RESOLVE_REPORT + ) +} + // NIP-43 relay membership admin commands /// NIP-43: Add a pubkey to the relay member list. pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030; @@ -478,6 +520,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_REPORT, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP29_EDIT_METADATA, @@ -487,6 +530,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_CREATE_INVITE, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -700,6 +748,15 @@ const _: () = assert!(!is_ephemeral(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(KIND_AGENT_TURN_METRIC <= u16::MAX as u32); +// Moderation kinds fit u16 and are neither replaceable nor ephemeral: +// 1984 is a regular event (persisted to the queue, never fanned out); +// 9040–9044 are direct commands (executed, never stored). +const _: () = assert!(KIND_REPORT <= u16::MAX as u32); +const _: () = assert!(KIND_MODERATION_RESOLVE_REPORT <= u16::MAX as u32); +const _: () = assert!(!is_ephemeral(KIND_REPORT)); +const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_BAN)); +const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_RESOLVE_REPORT)); +const _: () = assert!(!is_moderation_command_kind(KIND_REPORT)); #[cfg(test)] mod tests { diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d78c84350..eb791e441 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,6 +27,8 @@ pub mod feed; pub mod git_repo; /// Embedded database migrations. pub mod migration; +/// Community moderation: reports, bans/timeouts, audit actions. +pub mod moderation; /// Monthly table partition management. pub mod partition; /// Reaction persistence. @@ -2173,6 +2175,151 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool, community).await } + /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. + pub async fn insert_moderation_report( + &self, + community: CommunityId, + report: moderation::NewReport<'_>, + ) -> Result { + moderation::insert_report(&self.pool, community, report).await + } + + /// List moderation reports for a community, newest first. + pub async fn list_moderation_reports( + &self, + community: CommunityId, + status: Option<&str>, + limit: i64, + ) -> Result> { + moderation::list_reports(&self.pool, community, status, limit).await + } + + /// Fetch one moderation report by row id. + pub async fn get_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + ) -> Result> { + moderation::get_report(&self.pool, community, report_id).await + } + + /// Fetch one moderation report by signed NIP-56 report event id. + pub async fn get_moderation_report_by_event( + &self, + community: CommunityId, + report_event_id: &[u8], + ) -> Result> { + moderation::get_report_by_event(&self.pool, community, report_event_id).await + } + + /// Resolve, dismiss, or escalate an open moderation report. + pub async fn resolve_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, + ) -> Result { + moderation::resolve_report( + &self.pool, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + + /// Upsert a community ban for a member pubkey. + pub async fn ban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, + ) -> Result<()> { + moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await + } + + /// Lift a community ban for a member pubkey. + pub async fn unban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + moderation::unban_member(&self.pool, community, pubkey, actor).await + } + + /// Upsert a community timeout/write-block for a member pubkey. + pub async fn timeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, + ) -> Result<()> { + moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await + } + + /// Clear a community timeout/write-block for a member pubkey. + pub async fn untimeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + moderation::untimeout_member(&self.pool, community, pubkey, actor).await + } + + /// Fetch the active ban/timeout restriction state for enforcement hot paths. + pub async fn moderation_restriction_state( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + moderation::restriction_state(&self.pool, community, pubkey).await + } + + /// Fetch the full ban/timeout row for a member pubkey. + pub async fn get_community_ban( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result> { + moderation::get_ban(&self.pool, community, pubkey).await + } + + /// List currently restricted members in a community. + pub async fn list_community_restrictions( + &self, + community: CommunityId, + ) -> Result> { + moderation::list_restricted(&self.pool, community).await + } + + /// Insert a moderation audit action row. + pub async fn insert_moderation_action( + &self, + community: CommunityId, + action: moderation::NewAction<'_>, + ) -> Result { + moderation::insert_action(&self.pool, community, action).await + } + + /// List moderation audit action rows, newest first. + pub async fn list_moderation_actions( + &self, + community: CommunityId, + limit: i64, + ) -> Result> { + moderation::list_actions(&self.pool, community, limit).await + } + /// Return the current owner of git repo name `repo_id` in `community`, or /// `None` if unreserved. See [`git_repo::repo_name_owner`]. pub async fn repo_name_owner( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 69cb48df8..b255e79cb 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -471,7 +471,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 5); + assert_eq!(migrations.len(), 6); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -532,6 +532,29 @@ mod tests { assert!(migrations[4].sql.as_str().contains("search_tsv")); assert!(migrations[4].sql.as_str().contains("44200")); assert!(!migrations[0].sql.as_str().contains("44200")); + + // Community moderation (reports/bans/audit): additive migration, never + // folded into 0001 — same brownfield checksum rule as above. + assert_eq!(migrations[5].version, 6); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE moderation_reports")); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE community_bans")); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE moderation_actions")); + for action in crate::moderation::MODERATION_ACTION_CHECK_VOCAB { + assert!( + migrations[5].sql.as_str().contains(&format!("'{action}'")), + "migration 0006 moderation_actions.action CHECK must allow {action}" + ); + } + assert!(!migrations[0].sql.as_str().contains("moderation_reports")); } #[test] @@ -715,7 +738,15 @@ mod tests { run_migrations(&pool).await.expect("run migrations"); - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); + // Every embedded migration must apply, in order. Derive the expected + // list from the MIGRATOR itself so this doesn't go stale as additive + // migrations land (it previously hardcoded [1, 2, 3] and rotted). + let expected: Vec = { + let mut versions: Vec = MIGRATOR.iter().map(|m| m.version).collect(); + versions.sort_unstable(); + versions + }; + assert_eq!(applied_versions(&pool).await, expected); let sql = migration_sql(); let tables = create_tables(sql.as_str()); for table in [ diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs new file mode 100644 index 000000000..be8b712d4 --- /dev/null +++ b/crates/buzz-db/src/moderation.rs @@ -0,0 +1,894 @@ +//! Community moderation persistence (Phase 1 contract). +//! +//! Backs the NIP-56 report queue (`moderation_reports`), ban/timeout state +//! (`community_bans`), and the moderation audit trail (`moderation_actions`) +//! from `migrations/0006_moderation.sql`. +//! +//! ## Tenant invariant +//! Every function takes a [`CommunityId`] and touches exactly one community's +//! rows. Report/ban targets are resolved by callers under the requesting +//! `TenantContext` **before** they reach this module — no function here may +//! perform a cross-community or global lookup (MOD invariants, +//! `docs/spec/MultiTenantRelay.tla`). +//! +//! Lane ownership: L1 (Max). Signatures below are the contract; changes go +//! through the integration thread. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; +use uuid::Uuid; + +use crate::error::Result; +use crate::CommunityId; + +/// What a report points at. Exactly one target class per report row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReportTarget { + /// `e`-tag target: an event that must resolve inside the tenant. + Event(Vec), + /// `p`-only target: a community-local report about a pubkey. + Pubkey(Vec), + /// `x`-tag target: a media blob sha256, resolved via tenant-scoped refs. + Blob(Vec), +} + +/// Insert parameters for a new report row (from an accepted kind:1984 event). +#[derive(Debug, Clone)] +pub struct NewReport<'a> { + /// Signed kind:1984 event id (32 bytes) — idempotency key per community. + pub report_event_id: &'a [u8], + /// Reporter pubkey bytes. Mod-queue-visible; never revealed to the author. + pub reporter_pubkey: &'a [u8], + /// Resolved (in-tenant) target. + pub target: ReportTarget, + /// Channel inferred from an in-tenant target event row, when resolvable. + pub channel_id: Option, + /// NIP-56 report type (already validated by ingest). + pub report_type: &'a str, + /// Reporter's optional free-text note. + pub note: Option<&'a str>, +} + +/// A report row as read back for the moderation queue. +#[derive(Debug, Clone)] +pub struct ReportRecord { + /// Row id (unique within the community). + pub id: Uuid, + /// Signed kind:1984 event id. + pub report_event_id: Vec, + /// Reporter pubkey bytes. + pub reporter_pubkey: Vec, + /// Report target. + pub target: ReportTarget, + /// Inferred channel, if the target resolved to one. + pub channel_id: Option, + /// NIP-56 report type. + pub report_type: String, + /// Reporter's note. + pub note: Option, + /// `open` | `resolved` | `dismissed` | `escalated`. + pub status: String, + /// Resolving moderator, once resolved. + pub resolved_by: Option>, + /// Resolution timestamp. + pub resolved_at: Option>, + /// `moderation_actions` row that resolved this report. + pub action_id: Option, + /// Report creation time. + pub created_at: DateTime, +} + +/// Ban/timeout state for one member in one community. +#[derive(Debug, Clone)] +pub struct BanRecord { + /// Member pubkey bytes. + pub pubkey: Vec, + /// Whether the member is currently banned (check `ban_expires_at`). + pub banned: bool, + /// Ban expiry; `None` while `banned` ⇒ permanent. + pub ban_expires_at: Option>, + /// Moderator-supplied ban reason (private). + pub ban_reason: Option, + /// Write-block until this timestamp; `None` or past ⇒ not timed out. + pub muted_until: Option>, + /// Moderator-supplied timeout reason (private). + pub mute_reason: Option, + /// Moderator who last modified this row. + pub actor_pubkey: Vec, + /// Last modification time. + pub updated_at: DateTime, +} + +/// Audit action values accepted by the `moderation_actions.action` CHECK in +/// migration 0006. Keep this in lockstep with `migrations/0006_moderation.sql`. +pub const MODERATION_ACTION_CHECK_VOCAB: &[&str] = &[ + "delete_message", + "kick", + "ban", + "unban", + "timeout", + "untimeout", + "dismiss_report", + "escalate", + "resolve:delete", + "resolve:kick", + "resolve:ban", + "resolve:timeout", +]; + +/// Insert parameters for a moderation audit row. +#[derive(Debug, Clone)] +pub struct NewAction<'a> { + /// Acting moderator pubkey bytes. + pub actor_pubkey: &'a [u8], + /// `delete_message` | `kick` | `ban` | `unban` | `timeout` | `untimeout` + /// | `dismiss_report` | `escalate` | `resolve:*` decision rows (DB CHECK-enforced). + pub action: &'a str, + /// Actioned member, when the action targets a pubkey. + pub target_pubkey: Option<&'a [u8]>, + /// Actioned event, when the action targets an event. + pub target_event_id: Option<&'a [u8]>, + /// Channel context, when known. + pub channel_id: Option, + /// Machine-readable rule/reason code. + pub reason_code: Option<&'a str>, + /// Sanitized reason, safe for the public tombstone. + pub public_reason: Option<&'a str>, + /// Mod-only context; never leaves the audit surface. + pub private_reason: Option<&'a str>, + /// NIP-OA matched principal (`self` | `owner`) for ban enforcement audit. + pub matched_principal: Option<&'a str>, +} + +/// An audit row as read back for `buzz moderation audit`. +#[derive(Debug, Clone)] +pub struct ActionRecord { + /// Row id. + pub id: Uuid, + /// Acting moderator pubkey bytes. + pub actor_pubkey: Vec, + /// Action name. + pub action: String, + /// Actioned member. + pub target_pubkey: Option>, + /// Actioned event. + pub target_event_id: Option>, + /// Channel context. + pub channel_id: Option, + /// Machine-readable rule/reason code. + pub reason_code: Option, + /// Sanitized public reason. + pub public_reason: Option, + /// Mod-only reason. + pub private_reason: Option, + /// NIP-OA principal matched by enforcement, when relevant. + pub matched_principal: Option, + /// Action time. + pub created_at: DateTime, +} + +/// Insert a new report row. Idempotent on `(community, report_event_id)`: +/// re-ingesting the same signed report is a no-op returning the existing id. +pub async fn insert_report( + pool: &PgPool, + community: CommunityId, + report: NewReport<'_>, +) -> Result { + let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target { + ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None), + ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None), + ReportTarget::Blob(sha256) => ("blob", None, None, Some(sha256.as_slice())), + }; + + let row = sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, report_event_id, reporter_pubkey, target_kind, + target_event_id, target_pubkey, target_blob_sha256, channel_id, + report_type, note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (community_id, report_event_id) DO UPDATE SET + report_event_id = EXCLUDED.report_event_id + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(report.report_event_id) + .bind(report.reporter_pubkey) + .bind(target_kind) + .bind(target_event_id) + .bind(target_pubkey) + .bind(target_blob_sha256) + .bind(report.channel_id) + .bind(report.report_type) + .bind(report.note) + .fetch_one(pool) + .await?; + + Ok(row.try_get("id")?) +} + +/// List reports for the moderation queue, newest first. +/// `status = None` lists all; `Some("open")` etc. filters. +pub async fn list_reports( + pool: &PgPool, + community: CommunityId, + status: Option<&str>, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND ($2::text IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(community.as_uuid()) + .bind(status) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_report).collect() +} + +/// Fetch one report by row id. +pub async fn get_report( + pool: &PgPool, + community: CommunityId, + report_id: Uuid, +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND id = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(report_id) + .fetch_optional(pool) + .await?; + + row.map(row_to_report).transpose() +} + +/// Fetch one report by signed NIP-56 report event id. +pub async fn get_report_by_event( + pool: &PgPool, + community: CommunityId, + report_event_id: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND report_event_id = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(report_event_id) + .fetch_optional(pool) + .await?; + + row.map(row_to_report).transpose() +} + +/// Mark a report resolved/dismissed/escalated, linking the audit action. +/// Returns `false` if the report was not found or already closed. +pub async fn resolve_report( + pool: &PgPool, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, +) -> Result { + let result = sqlx::query( + r#" + UPDATE moderation_reports + SET status = $3, resolved_by = $4, resolved_at = now(), action_id = $5 + WHERE community_id = $1 AND id = $2 AND status = 'open' + "#, + ) + .bind(community.as_uuid()) + .bind(report_id) + .bind(status) + .bind(resolved_by) + .bind(action_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Upsert a ban: sets `banned = true` with optional expiry + reason. +pub async fn ban_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, banned, ban_expires_at, ban_reason, actor_pubkey + ) VALUES ($1, $2, true, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + ban_expires_at = EXCLUDED.ban_expires_at, + ban_reason = EXCLUDED.ban_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expires_at) + .bind(reason) + .bind(actor) + .execute(pool) + .await?; + + Ok(()) +} + +/// Lift a ban. Returns `false` if the member was not banned. +pub async fn unban_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET banned = false, ban_expires_at = NULL, ban_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND banned = true + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Upsert a timeout: sets `muted_until` + reason. +pub async fn timeout_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, muted_until, mute_reason, actor_pubkey + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + muted_until = EXCLUDED.muted_until, + mute_reason = EXCLUDED.mute_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(muted_until) + .bind(reason) + .bind(actor) + .execute(pool) + .await?; + + Ok(()) +} + +/// Clear a timeout early. Returns `false` if the member was not timed out. +pub async fn untimeout_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET muted_until = NULL, mute_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND muted_until > now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Restriction snapshot consumed by the auth-seam gate (L4) and write gates. +/// +/// One cheap read per check: `banned` already accounts for expiry; +/// `muted_until` is returned raw so the caller can render the timestamp in +/// the `restricted:` message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RestrictionState { + /// Currently banned (row exists, `banned`, unexpired). + pub banned: bool, + /// Active timeout expiry, if in the future. + pub muted_until: Option>, +} + +/// Fetch the current restriction state for a pubkey in one community. +/// Missing row ⇒ `RestrictionState::default()` (unrestricted). +pub async fn restriction_state( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + r#" + SELECT + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + CASE WHEN muted_until > now() THEN muted_until ELSE NULL END AS muted_until + FROM community_bans + WHERE community_id = $1 AND pubkey = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(RestrictionState { + banned: row.try_get("banned")?, + muted_until: row.try_get("muted_until")?, + }), + None => Ok(RestrictionState::default()), + } +} + +/// Fetch the full ban/timeout row (moderation queue / audit views). +pub async fn get_ban( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 AND pubkey = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + row.map(row_to_ban).transpose() +} + +/// List currently-restricted members (active ban or timeout) for the queue. +pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result> { + let rows = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 + AND ( + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + OR muted_until > now() + ) + ORDER BY updated_at DESC + "#, + ) + .bind(community.as_uuid()) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_ban).collect() +} + +/// Insert a moderation audit row, returning its id. +pub async fn insert_action( + pool: &PgPool, + community: CommunityId, + action: NewAction<'_>, +) -> Result { + let row = sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, target_event_id, + channel_id, reason_code, public_reason, private_reason, matched_principal + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(action.actor_pubkey) + .bind(action.action) + .bind(action.target_pubkey) + .bind(action.target_event_id) + .bind(action.channel_id) + .bind(action.reason_code) + .bind(action.public_reason) + .bind(action.private_reason) + .bind(action.matched_principal) + .fetch_one(pool) + .await?; + + Ok(row.try_get("id")?) +} + +/// List audit rows, newest first (`buzz moderation audit`). +pub async fn list_actions( + pool: &PgPool, + community: CommunityId, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, actor_pubkey, action, target_pubkey, target_event_id, channel_id, + reason_code, public_reason, private_reason, matched_principal, created_at + FROM moderation_actions + WHERE community_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(community.as_uuid()) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_action).collect() +} + +fn row_to_report(row: sqlx::postgres::PgRow) -> Result { + let target_kind: String = row.try_get("target_kind")?; + let target = match target_kind.as_str() { + "event" => ReportTarget::Event(row.try_get("target_event_id")?), + "pubkey" => ReportTarget::Pubkey(row.try_get("target_pubkey")?), + "blob" => ReportTarget::Blob(row.try_get("target_blob_sha256")?), + other => { + return Err(crate::error::DbError::InvalidData(format!( + "invalid report target_kind: {other}" + ))) + } + }; + + Ok(ReportRecord { + id: row.try_get("id")?, + report_event_id: row.try_get("report_event_id")?, + reporter_pubkey: row.try_get("reporter_pubkey")?, + target, + channel_id: row.try_get("channel_id")?, + report_type: row.try_get("report_type")?, + note: row.try_get("note")?, + status: row.try_get("status")?, + resolved_by: row.try_get("resolved_by")?, + resolved_at: row.try_get("resolved_at")?, + action_id: row.try_get("action_id")?, + created_at: row.try_get("created_at")?, + }) +} + +fn row_to_ban(row: sqlx::postgres::PgRow) -> Result { + Ok(BanRecord { + pubkey: row.try_get("pubkey")?, + banned: row.try_get("banned")?, + ban_expires_at: row.try_get("ban_expires_at")?, + ban_reason: row.try_get("ban_reason")?, + muted_until: row.try_get("muted_until")?, + mute_reason: row.try_get("mute_reason")?, + actor_pubkey: row.try_get("actor_pubkey")?, + updated_at: row.try_get("updated_at")?, + }) +} + +fn row_to_action(row: sqlx::postgres::PgRow) -> Result { + Ok(ActionRecord { + id: row.try_get("id")?, + actor_pubkey: row.try_get("actor_pubkey")?, + action: row.try_get("action")?, + target_pubkey: row.try_get("target_pubkey")?, + target_event_id: row.try_get("target_event_id")?, + channel_id: row.try_get("channel_id")?, + reason_code: row.try_get("reason_code")?, + public_reason: row.try_get("public_reason")?, + private_reason: row.try_get("private_reason")?, + matched_principal: row.try_get("matched_principal")?, + created_at: row.try_get("created_at")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("moderation-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + fn random_32() -> Vec { + let mut bytes = Vec::with_capacity(32); + bytes.extend_from_slice(Uuid::new_v4().as_bytes()); + bytes.extend_from_slice(Uuid::new_v4().as_bytes()); + bytes + } + + fn new_report<'a>( + report_event_id: &'a [u8], + reporter_pubkey: &'a [u8], + target_event_id: &'a [u8], + note: Option<&'a str>, + ) -> NewReport<'a> { + NewReport { + report_event_id, + reporter_pubkey, + target: ReportTarget::Event(target_event_id.to_vec()), + channel_id: None, + report_type: "spam", + note, + } + } + + /// Community moderation restrictions are tenant-scoped. This guards the same + /// mutation class as the TLA⁺ tenant-fence invariant: a ban in community A + /// must not restrict the same pubkey in community B, through either the hot + /// `restriction_state` read or the queue-facing `list_restricted` read. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn restrictions_are_confined_to_their_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member( + &pool, + community_a, + &pubkey, + &actor, + Some("tenant fence test"), + None, + ) + .await + .expect("ban in community A"); + + let state_a = restriction_state(&pool, community_a, &pubkey) + .await + .expect("restriction_state A"); + assert!(state_a.banned, "pubkey must be banned in community A"); + + let state_b = restriction_state(&pool, community_b, &pubkey) + .await + .expect("restriction_state B"); + assert!( + !state_b.banned && state_b.muted_until.is_none(), + "ban in A must not restrict the same pubkey in community B" + ); + + let restricted_a = list_restricted(&pool, community_a) + .await + .expect("list restricted A"); + assert!( + restricted_a.iter().any(|row| row.pubkey == pubkey), + "community A restricted list must include the banned pubkey" + ); + + let restricted_b = list_restricted(&pool, community_b) + .await + .expect("list restricted B"); + assert!( + restricted_b.iter().all(|row| row.pubkey != pubkey), + "community B restricted list must not include community A's ban" + ); + } + + /// Ban expiry is evaluated in SQL, while a live timeout on the same row keeps + /// the member restricted for writes. This protects the one-row/two-restriction + /// shape used by L4's auth and ingest gates. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_ban_does_not_hide_active_timeout() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member( + &pool, + community, + &pubkey, + &actor, + Some("expired ban"), + Some(Utc::now() - Duration::hours(1)), + ) + .await + .expect("insert expired ban"); + timeout_member( + &pool, + community, + &pubkey, + &actor, + Utc::now() + Duration::hours(1), + Some("active timeout"), + ) + .await + .expect("insert active timeout"); + + let state = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state"); + assert!(!state.banned, "expired ban must evaluate inactive"); + assert!( + state.muted_until.is_some(), + "active timeout must survive an expired ban on the same row" + ); + + let ban = get_ban(&pool, community, &pubkey) + .await + .expect("get ban") + .expect("restriction row exists"); + assert!( + !ban.banned, + "get_ban must also evaluate expired ban inactive" + ); + assert!(ban.muted_until.is_some(), "get_ban must preserve timeout"); + + let restricted = list_restricted(&pool, community) + .await + .expect("list restricted"); + let listed = restricted + .iter() + .find(|row| row.pubkey == pubkey) + .expect("timeout-only row remains listed"); + assert!( + !listed.banned, + "list_restricted reports expired ban inactive" + ); + assert!( + listed.muted_until.is_some(), + "list_restricted preserves timeout" + ); + } + + /// Re-ingesting the same signed report is idempotent by event id and must not + /// reopen or otherwise reset a report that a moderator already resolved. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_reingest_returns_same_id_and_preserves_resolution() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let report_event_id = random_32(); + let reporter = random_32(); + let target_event_id = random_32(); + let resolver = random_32(); + + let report = new_report(&report_event_id, &reporter, &target_event_id, Some("first")); + let first_id = insert_report(&pool, community, report) + .await + .expect("insert report"); + + assert!( + resolve_report(&pool, community, first_id, "resolved", &resolver, None) + .await + .expect("resolve report"), + "first resolve should close the report" + ); + + let duplicate = new_report(&report_event_id, &reporter, &target_event_id, Some("retry")); + let second_id = insert_report(&pool, community, duplicate) + .await + .expect("re-ingest report"); + assert_eq!(first_id, second_id, "re-ingest must return the same row id"); + + let row = get_report(&pool, community, first_id) + .await + .expect("get report") + .expect("report exists"); + let row_by_event = get_report_by_event(&pool, community, &report_event_id) + .await + .expect("get report by event id") + .expect("report exists by event id"); + assert_eq!( + row_by_event.id, first_id, + "report event id lookup must return the same row" + ); + assert_eq!( + row.status, "resolved", + "re-ingest must not reopen the report" + ); + assert!( + row.resolved_at.is_some(), + "resolution timestamp is preserved" + ); + assert_eq!( + row.resolved_by.as_deref(), + Some(resolver.as_slice()), + "resolving moderator is preserved" + ); + } + + /// `resolve_report` is a guarded transition out of `open`; a second resolve + /// on a closed report must be a no-op and return `false`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn resolve_report_returns_false_after_report_is_closed() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let report_event_id = random_32(); + let reporter = random_32(); + let target_event_id = random_32(); + let resolver = random_32(); + + let report_id = insert_report( + &pool, + community, + new_report(&report_event_id, &reporter, &target_event_id, None), + ) + .await + .expect("insert report"); + + assert!( + resolve_report(&pool, community, report_id, "dismissed", &resolver, None) + .await + .expect("first resolve"), + "first resolve should update the open report" + ); + assert!( + !resolve_report(&pool, community, report_id, "resolved", &resolver, None) + .await + .expect("second resolve"), + "second resolve should return false once the report is closed" + ); + } +} diff --git a/crates/buzz-pubsub/src/conn_control.rs b/crates/buzz-pubsub/src/conn_control.rs new file mode 100644 index 000000000..a562980a7 --- /dev/null +++ b/crates/buzz-pubsub/src/conn_control.rs @@ -0,0 +1,210 @@ +//! Cross-pod connection-control commands over Redis pub/sub. +//! +//! Under horizontal scaling a member's live connections may land on any pod, +//! so a moderation action taken on one pod (a ban) must reach the pod holding +//! the victim's socket. This module carries connection-control intents — today +//! only "disconnect this pubkey" — to every pod, which each apply locally +//! against their own [`crate::ConnectionManager`]. +//! +//! This is deliberately a **separate** channel from `cache_invalidation`: a +//! cache-key drop is a pure, idempotent hint (the DB is re-read on the next +//! access), whereas a disconnect is an imperative, non-idempotent action on a +//! live socket. Folding it into the cache-invalidation enum would break that +//! module's stated invariant ("a pure cache-key drop, never an evict payload"). +//! The DB ban row remains the durable backstop: even if a disconnect message is +//! dropped, the next auth attempt is refused at the auth seam. + +use buzz_core::{CommunityId, TenantContext}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::topic::BUZZ_PREFIX; + +/// Tenant-local Redis pub/sub channel suffix for connection-control messages. +pub const CONN_CONTROL_SUFFIX: &str = "conn-control"; + +/// Pattern the subscriber uses to receive connection-control messages for every +/// community this pod may hold connections for. +pub const CONN_CONTROL_PATTERN: &str = "buzz:*:conn-control"; + +/// Redis pub/sub channel for connection-control messages under `ctx`. +pub fn conn_control_channel(ctx: &TenantContext) -> String { + format!("{BUZZ_PREFIX}:{}:{CONN_CONTROL_SUFFIX}", ctx.community()) +} + +/// Parse a connection-control Redis channel into its scoped community id. +pub fn parse_conn_control_channel(channel: &str) -> Option { + let mut parts = channel.split(':'); + if parts.next()? != BUZZ_PREFIX { + return None; + } + let community_id = Uuid::parse_str(parts.next()?).ok()?; + if parts.next()? != CONN_CONTROL_SUFFIX { + return None; + } + if parts.next().is_some() { + return None; + } + Some(CommunityId::from_uuid(community_id)) +} + +/// A connection-control command to apply on every pod. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "op")] +pub enum ConnControl { + /// Disconnect every live connection authenticated as `pubkey` in the + /// carrying community — live ban enforcement. `pubkey` is 32 raw bytes. + /// `event_id` and `reason` reproduce the same NIP-01 `OK` frame the origin + /// pod sent, so a member disconnected on any pod learns why. + DisconnectPubkey { + /// Banned member's pubkey bytes. + pubkey: Vec, + /// Id echoed in the closing `OK` frame (the ban event's id on origin). + event_id: String, + /// Human-readable close reason for the `OK` frame. + reason: String, + }, +} + +/// A connection-control command received from a community-scoped Redis channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopedConnControl { + /// Community whose connections the command applies to. + pub community_id: CommunityId, + /// The tenant-local connection-control command. + pub command: ConnControl, +} + +/// Initial reconnect backoff (1 second). +const BACKOFF_INITIAL_SECS: u64 = 1; +/// Maximum reconnect backoff (30 seconds). +const BACKOFF_MAX_SECS: u64 = 30; + +/// Subscribes to `buzz:*:conn-control` and forwards scoped commands to the +/// broadcast. Mirrors [`crate::cache_invalidation::run_cache_invalidation_subscriber`]: +/// a reconnect loop with exponential backoff. Never returns. +pub async fn run_conn_control_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + + loop { + match connect_and_subscribe(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis conn-control stream ended (clean disconnect) — reconnecting in {backoff_secs}s" + ); + } + Err(e) => { + tracing::error!("Redis conn-control error: {e} — reconnecting in {backoff_secs}s"); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + + tracing::info!("Attempting to reconnect to Redis conn-control..."); + } +} + +async fn connect_and_subscribe( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut conn = client.get_async_pubsub().await?; + + conn.psubscribe(CONN_CONTROL_PATTERN).await?; + + tracing::info!("Redis conn-control subscriber connected — listening on {CONN_CONTROL_PATTERN}"); + + let mut stream = conn.on_message(); + while let Some(msg) = stream.next().await { + let channel = msg.get_channel_name(); + let Some(community_id) = parse_conn_control_channel(channel) else { + tracing::warn!("Received conn-control message on unexpected channel: {channel}"); + continue; + }; + + let payload: String = match msg.get_payload() { + Ok(p) => p, + Err(e) => { + tracing::warn!("Failed to get conn-control payload: {e}"); + continue; + } + }; + + let command: ConnControl = match serde_json::from_str(&payload) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to deserialize conn-control message: {e}"); + continue; + } + }; + + let scoped = ScopedConnControl { + community_id, + command, + }; + + if broadcast_tx.send(scoped).is_err() { + tracing::trace!("No conn-control receivers — message dropped"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(id: u128, host: &str) -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host) + } + + #[test] + fn conn_control_channel_is_community_scoped() { + let a = ctx(0xaaaa, "a.example"); + let b = ctx(0xbbbb, "b.example"); + assert_eq!( + conn_control_channel(&a), + format!("buzz:{}:conn-control", a.community()) + ); + assert_ne!(conn_control_channel(&a), conn_control_channel(&b)); + } + + #[test] + fn parse_round_trips_the_community() { + let a = ctx(0x1234, "a.example"); + let channel = conn_control_channel(&a); + assert_eq!(parse_conn_control_channel(&channel), Some(a.community())); + } + + #[test] + fn parse_rejects_foreign_channels() { + assert_eq!( + parse_conn_control_channel("buzz:not-a-uuid:conn-control"), + None + ); + assert_eq!(parse_conn_control_channel("buzz:*:cache-invalidate"), None); + let a = ctx(0x1234, "a.example"); + let extended = format!("{}:extra", conn_control_channel(&a)); + assert_eq!(parse_conn_control_channel(&extended), None); + } + + #[test] + fn disconnect_command_serde_round_trips() { + let cmd = ConnControl::DisconnectPubkey { + pubkey: vec![7u8; 32], + event_id: "abc123".to_string(), + reason: "blocked: you are banned from this community".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); + } +} diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 550f32d65..eae8c5ef9 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -23,6 +23,8 @@ /// Cross-pod cache-key invalidation over Redis pub/sub. pub mod cache_invalidation; +/// Cross-pod connection-control commands over Redis pub/sub. +pub mod conn_control; /// Error types for pub/sub operations. pub mod error; /// Redis-backed NIP-98 replay seen-set. @@ -52,6 +54,7 @@ use tokio::sync::{broadcast, mpsc, Mutex}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; +use crate::conn_control::{conn_control_channel, ConnControl, ScopedConnControl}; pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey}; /// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers. @@ -106,6 +109,7 @@ pub struct PubSubManager { subscription_rx: Mutex>>, broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, + conn_control_tx: broadcast::Sender, } impl PubSubManager { @@ -121,6 +125,7 @@ impl PubSubManager { ) -> Result { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); + let (conn_control_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); Ok(Self { @@ -132,6 +137,7 @@ impl PubSubManager { subscription_rx: Mutex::new(Some(subscription_rx)), broadcast_tx, cache_invalidation_tx, + conn_control_tx, }) } @@ -164,6 +170,16 @@ impl PubSubManager { .await; } + /// Starts the connection-control subscriber loop with automatic + /// reconnection. Runs forever — spawn this in a background task. + pub async fn run_conn_control_subscriber(self: Arc) { + conn_control::run_conn_control_subscriber( + self.redis_url.clone(), + self.conn_control_tx.clone(), + ) + .await; + } + /// Returns a new broadcast receiver for locally-published channel events. pub fn subscribe_local(&self) -> broadcast::Receiver { self.broadcast_tx.subscribe() @@ -244,6 +260,11 @@ impl PubSubManager { self.cache_invalidation_tx.subscribe() } + /// Returns a new broadcast receiver for cross-pod connection-control commands. + pub fn subscribe_conn_control(&self) -> broadcast::Receiver { + self.conn_control_tx.subscribe() + } + /// Publish a cache-key drop to all pods. Fire-and-forget at the call site: /// the local cache is already dropped synchronously; this carries the same /// drop cross-pod. A dropped publish is backstopped by the REQ denial-path @@ -263,6 +284,26 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a connection-control command to all pods. Used for live ban + /// enforcement: the banning pod disconnects any local sockets synchronously + /// and calls this to reach the banned member's sockets on other pods. The DB + /// ban row is the durable backstop, so a dropped publish still refuses the + /// next auth attempt; callers may spawn this without awaiting delivery. + pub async fn publish_conn_control( + &self, + ctx: &TenantContext, + command: &ConnControl, + ) -> Result { + let mut conn = self.pool.get().await?; + let payload = serde_json::to_string(command)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(conn_control_channel(ctx)) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) + } + /// Publish an event to the Redis channel. Returns subscriber count. /// /// Routing note (NIP-ER author-private reminders): events are keyed by diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c32e80bbe..affea6919 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::{ - extract::{Path, Query, State}, + extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -1635,6 +1635,203 @@ async fn synthesize_presence( Some(events) } +// ── Moderation queue reads (L6 — Quinn) ─────────────────────────────────────── +// +// Mod-only structured rows (`moderation_reports`/`moderation_actions`/ +// `community_bans`) are not nostr events, so they are served over dedicated +// NIP-98-authed GET endpoints rather than the REQ/`/query` path (which would +// force a synthetic event shape and thread a privileged branch onto the shared +// read hot path). Gated on `ModerationAction::ViewQueue` via the one capability +// helper — never an inline role check. Host-scoped: community from the request +// host, no channel context (queue reads are community-wide). + +/// Shared prelude for a moderation read: bind tenant, verify NIP-98 GET auth, +/// replay-check, and confirm the caller may view the queue. +/// +/// `raw_query` is the request's raw query string (from [`axum::extract::RawQuery`]), +/// e.g. `Some("limit=20&status=open")`. NIP-98 signs the *full* request URL, so the +/// client's `u` tag includes any query string; the expected URL reconstructed here +/// must therefore append the same query verbatim or query-bearing reads +/// (`reports?limit=…`, `audit?limit=…`) 401 on a URL mismatch. Query-less reads +/// (`restricted`) pass `None` and keep the bare-path expectation. The verbatim +/// request query is used (not a re-serialized parse) so the match stays byte-exact +/// with what the client signed regardless of param order or encoding. +async fn authorize_moderation_read( + state: &Arc, + headers: &HeaderMap, + path: &str, + raw_query: Option<&str>, +) -> Result)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = + verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + check_nip98_replay(state, &tenant, event_id_bytes).await?; + let pubkey_bytes = pubkey.to_bytes().to_vec(); + + crate::handlers::moderation_authz::authorize_moderation_action( + &tenant, + state, + &pubkey_bytes, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + + Ok(tenant) +} + +/// Cap on rows returned by a single moderation read. +const MODERATION_READ_LIMIT: i64 = 500; + +/// Optional `?status=` and `?limit=` query for moderation reads. +#[derive(serde::Deserialize, Default)] +pub struct ModerationReadQuery { + status: Option, + limit: Option, +} + +fn clamp_limit(requested: Option) -> i64 { + requested + .filter(|n| *n > 0) + .map(|n| n.min(MODERATION_READ_LIMIT)) + .unwrap_or(MODERATION_READ_LIMIT) +} + +/// `GET /moderation/reports` — the moderation queue (NIP-98 + mod-authz). +pub async fn moderation_reports( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = authorize_moderation_read( + &state, + &headers, + "/moderation/reports", + raw_query.as_deref(), + ) + .await?; + let rows = state + .db + .list_moderation_reports( + tenant.community(), + q.status.as_deref(), + clamp_limit(q.limit), + ) + .await + .map_err(|e| internal_error(&format!("list reports: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) +} + +/// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). +pub async fn moderation_audit( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = + authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) + .await?; + let rows = state + .db + .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) + .await + .map_err(|e| internal_error(&format!("list actions: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) +} + +/// `GET /moderation/restricted` — currently banned/timed-out members. +pub async fn moderation_restricted( + State(state): State>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let tenant = + authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; + let rows = state + .db + .list_community_restrictions(tenant.community()) + .await + .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) +} + +fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { + let (target_kind, target) = match &r.target { + buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id)), + buzz_db::moderation::ReportTarget::Pubkey(pk) => ("pubkey", hex::encode(pk)), + buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha)), + }; + serde_json::json!({ + "id": r.id, + "report_event_id": hex::encode(&r.report_event_id), + "reporter_pubkey": hex::encode(&r.reporter_pubkey), + "target_kind": target_kind, + "target": target, + "channel_id": r.channel_id, + "report_type": r.report_type, + "note": r.note, + "status": r.status, + "resolved_by": r.resolved_by.as_ref().map(hex::encode), + "resolved_at": r.resolved_at, + "action_id": r.action_id, + "created_at": r.created_at, + }) +} + +fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value { + serde_json::json!({ + "id": a.id, + "actor_pubkey": hex::encode(&a.actor_pubkey), + "action": a.action, + "target_pubkey": a.target_pubkey.as_ref().map(hex::encode), + "target_event_id": a.target_event_id.as_ref().map(hex::encode), + "channel_id": a.channel_id, + "reason_code": a.reason_code, + "public_reason": a.public_reason, + "private_reason": a.private_reason, + "matched_principal": a.matched_principal, + "created_at": a.created_at, + }) +} + +fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { + serde_json::json!({ + "pubkey": hex::encode(&b.pubkey), + "banned": b.banned, + "ban_expires_at": b.ban_expires_at, + "ban_reason": b.ban_reason, + "muted_until": b.muted_until, + "mute_reason": b.mute_reason, + "actor_pubkey": hex::encode(&b.actor_pubkey), + "updated_at": b.updated_at, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1927,6 +2124,133 @@ mod tests { ); } + /// Mirror of the query-reconstruction `authorize_moderation_read` performs + /// before calling [`nip98_expected_url`], so the tests below pin the exact + /// seam without a DB harness. Kept in lockstep with the production match arm. + fn moderation_read_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, + raw_query: Option<&str>, + ) -> String { + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + nip98_expected_url(config_relay_url, tenant, &path_with_query) + } + + /// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full* + /// request URL — including `?limit=…&status=…` — but the relay used to + /// reconstruct the expected URL from the bare path only, so + /// `buzz moderation reports` / `audit` 401'd on a NIP-98 URL mismatch in + /// normal use. This pins that a query-bearing GET verifies iff the expected + /// URL carries the same query verbatim. Bites if the query is ever dropped + /// from `authorize_moderation_read`'s expected-URL reconstruction. + #[test] + fn moderation_read_query_bearing_nip98_event_verifies_with_matching_query() { + let keys = Keys::generate(); + // CLI signs the URL it actually requests, query and all. + let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/reports", + Some("limit=20&status=open"), + ); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("query-bearing moderation read must verify against the same query"); + assert_eq!(pubkey, keys.public_key()); + } + + /// Anti-regression control proving the fix is load-bearing: the same + /// query-bearing event MUST be rejected when the expected URL omits the + /// query — the pre-fix behavior. If this ever passes, the relay has + /// silently reverted to bare-path reconstruction. + #[test] + fn moderation_read_query_bearing_nip98_event_rejected_against_bare_path() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + // No query — the broken pre-fix reconstruction. + let bare_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/reports", + None, + ); + + let (status, body) = verify_bridge_auth(&headers, "GET", &bare_url, None, true) + .expect_err("query-signed event MUST NOT match a bare-path expected URL"); + assert_eq!(status, StatusCode::UNAUTHORIZED); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + msg.contains("URL mismatch"), + "rejection must be a URL mismatch; got body = {body:?}" + ); + } + + /// `audit?limit=20` — the second query-bearing read path — verifies the + /// same way. Pins that the reconstruction is generic over the path, not + /// special-cased to `reports`. + #[test] + fn moderation_read_audit_query_bearing_nip98_event_verifies() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/audit?limit=20"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/audit", + Some("limit=20"), + ); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("audit query-bearing read must verify"); + assert_eq!(pubkey, keys.public_key()); + } + + /// `restricted` has no query and passes `None`, so its expected URL stays + /// the bare path — a query-less signed event verifies. Pins Wren's + /// "preserve restricted no-query behavior" checklist item. + #[test] + fn moderation_read_restricted_no_query_still_verifies() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/restricted"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/restricted", + None, + ); + assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("query-less restricted read must verify against the bare path"); + assert_eq!(pubkey, keys.public_key()); + } + /// `nip98_expected_url` derives host from `tenant`, not from /// `config_relay_url`. Pin both directions: changing the tenant's host /// changes the output; changing the config's host does NOT. diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index cadbbf116..f6765553e 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -177,6 +177,7 @@ pub async fn handle_connection( state.conn_manager.register( conn_id, tx.clone(), + ctrl_tx.clone(), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), @@ -294,6 +295,17 @@ async fn send_loop_inner( // so backpressure-triggered shutdown isn't starved by queued data. biased; _ = cancel.cancelled() => { + // Drain any queued control frames before closing. A ban + // disconnect queues its `OK false "blocked: …"` reason frame on + // ctrl and then cancels; without this drain the biased branch + // would send Close first and the client would never learn why + // (the top-of-loop drain does not run again after we break). + // This makes "queue frame on ctrl, then cancel" a safe idiom. + while let Ok(ctrl_msg) = ctrl_rx.try_recv() { + if ws_send.send(ctrl_msg).await.is_err() { + break; + } + } let _ = ws_send.send(WsMessage::Close(None)).await; break; } @@ -699,4 +711,43 @@ mod tests { vec!["control", "data-0", "data-1"] ); } + + #[tokio::test] + async fn send_loop_flushes_queued_control_before_close_on_cancel() { + // A ban disconnect queues its `OK false "blocked: …"` reason frame on + // the control channel and then cancels the token (B3). The biased + // select polls the cancel branch first, so the reason frame would be + // stranded unless the cancel branch drains ctrl before emitting Close. + // This test exercises `send_loop_inner` end-to-end to prove the reason + // frame reaches the client, in order, ahead of the Close. + let (_data_tx, data_rx) = mpsc::channel(1); + let (ctrl_tx, ctrl_rx) = mpsc::channel(1); + ctrl_tx + .send(WsMessage::Text("blocked: you are banned".into())) + .await + .expect("queue ban reason frame"); + + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!( + state.messages.len(), + 2, + "reason frame then Close, nothing else" + ); + match &state.messages[0] { + WsMessage::Text(text) => { + assert_eq!(text.as_str(), "blocked: you are banned") + } + other => panic!("expected the ban reason frame first, got {other:?}"), + } + assert!( + matches!(state.messages[1], WsMessage::Close(_)), + "Close is sent only after the reason frame is flushed" + ); + } } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index bc356979f..a5d6be50c 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -11,6 +11,7 @@ use std::sync::Arc; +use axum::extract::ws::Message as WsMessage; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -90,6 +91,98 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + // Community ban gate (NIP-42 seam). Runs immediately after auth + // verification succeeds and before the allowlist and relay-membership + // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the + // MOD-7/M20 invariant (a ban must block connection auth even for open + // channels — enforcement is structural, not filtered later). A banned + // principal gets the standard protocol denial and the connection is + // dropped with zero further processing. + // + // NIP-OA cascade: a ban on the authenticated pubkey blocks it directly; + // a ban on its cryptographically-proven owner cascades to the agent + // (owner ban ⇒ agents banned; agent ban is agent-only). The owner is + // extracted from the self-proving auth tag with no DB round-trip. + { + // Fail closed on a DB error, but distinguish it from a real ban: + // a transient blip must deny (never let a banned principal + // through) without telling an innocent user they are banned and + // pinning `Failed` for the connection's life on a false premise. + // `Banned` claims the ban; `DbError` denies with `error: internal` + // (mirrors the ingest write-path gate). + enum BanOutcome { + Clear, + Banned, + DbError, + } + + let mut outcome = match state + .db + .moderation_restriction_state(conn.tenant.community(), pubkey.as_bytes()) + .await + { + Ok(state) if state.banned => BanOutcome::Banned, + Ok(_) => BanOutcome::Clear, + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + "ban-state DB lookup failed, denying (fail-closed)"); + BanOutcome::DbError + } + }; + + // Cascade: check the proven NIP-OA owner only if the agent itself + // is clear (a DB error already denies; a direct ban already blocks + // — both skip the needless second DB read). + if matches!(outcome, BanOutcome::Clear) { + if let Some(owner) = crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag_json.as_deref(), + ) { + outcome = match state + .db + .moderation_restriction_state(conn.tenant.community(), owner.as_bytes()) + .await + { + Ok(state) if state.banned => BanOutcome::Banned, + Ok(_) => BanOutcome::Clear, + Err(e) => { + warn!(conn_id = %conn_id, owner = %owner.to_hex(), error = %e, + "owner ban-state DB lookup failed, denying (fail-closed)"); + BanOutcome::DbError + } + }; + } + } + + let denial: Option<(&str, &str)> = match outcome { + BanOutcome::Clear => None, + BanOutcome::Banned => { + Some(("banned", "blocked: you are banned from this community")) + } + BanOutcome::DbError => Some(( + "ban_check_error", + "error: internal error checking restriction state", + )), + }; + + if let Some((metric_reason, deny_reason)) = denial { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); + metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + // Decision 4: banned ⇒ OK false + immediate WebSocket close. + // Route the reason frame on the control channel (not `send`, + // which uses the data channel and would race the cancel), so + // the send loop drains it ahead of the Close it emits on + // cancel. Then cancel to close the socket immediately. + let _ = conn.ctrl_tx.try_send(WsMessage::Text( + RelayMessage::ok(&event_id_hex, false, deny_reason).into(), + )); + conn.cancel.cancel(); + return; + } + } + // Pubkey allowlist gate — only for pubkey-only auth. if state.config.pubkey_allowlist_enabled && auth_ctx.auth_method == buzz_auth::AuthMethod::Nip42 diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ad26fe5f2..9ab57bab8 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1332,9 +1332,11 @@ mod tests { ) -> (Uuid, mpsc::Receiver) { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(10); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1969,9 +1971,11 @@ mod tests { fn register_conn(state: &AppState, pubkey: Option>) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2292,9 +2296,11 @@ mod tests { ) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9abb5948f..4b348a520 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -22,16 +22,17 @@ use buzz_core::kind::{ KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, - KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; @@ -158,6 +159,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), + // NIP-56 reports are ordinary member writes into the mod-only queue. + // Ingest persists them to `moderation_reports` and suppresses public + // storage/fanout; reports are signals, never enforcement triggers. + KIND_REPORT => Ok(Scope::MessagesWrite), + // Community moderation commands are direct, mod-authz-gated writes. + // Scope only proves the transport can submit message writes; the + // command handler owns role/capability authorization. + k if buzz_core::kind::is_moderation_command_kind(k) => Ok(Scope::MessagesWrite), // NIP-51 standard lists and NIP-65 relay list — user-owned global state, // same ownership shape as kind:3 (contacts) and kind:0 (profile). KIND_MUTE_LIST @@ -366,6 +375,15 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_GIT_STATUS_MERGED | KIND_GIT_STATUS_CLOSED | KIND_GIT_STATUS_DRAFT + // Community moderation commands (9040–9044): community-global + // direct commands, same model as the NIP-43 9030-series. A stray + // `h` tag must never channel-scope them (pinned contract — + // handlers/moderation_commands.rs routing docs). + | KIND_MODERATION_BAN + | KIND_MODERATION_UNBAN + | KIND_MODERATION_TIMEOUT + | KIND_MODERATION_UNTIMEOUT + | KIND_MODERATION_RESOLVE_REPORT // NIP-43: relay admin commands and leave requests are global — they // must never be channel-scoped, even if the event carries a stray `h` tag. | RELAY_ADMIN_ADD_MEMBER @@ -1415,6 +1433,94 @@ async fn ingest_event_inner( return super::command_executor::handle_command(tenant, state, event, auth).await; } + // NIP-56 reports are persisted only to the mod queue. They are not stored in + // the public events table and never fan out to subscribers. Reports remain + // available while timed out so users can signal abuse during a write-block. + // A banned actor in the rare missed-disconnect window may also submit a + // report; that is tolerated because reports are non-actioning signals and + // remain visible only to moderators. + if kind_u32 == KIND_REPORT { + super::report::handle_report_event(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // Community moderation commands (9040–9044) are direct, community-global + // mutations. They are never stored or fanned out as ordinary events; the + // handler writes the durable audit/enforcement rows after its own capability + // authorization. These commands are intentionally routed before the + // timeout/write-block gate below: restriction-lifting commands must remain + // available so a wrongly restricted admin is not stranded, while banned + // actors are handled by the auth seam and live-disconnect enforcement. + if buzz_core::kind::is_moderation_command_kind(kind_u32) { + super::moderation_commands::handle_moderation_command(tenant, state, &event) + .await + .map_err(IngestError::Rejected)?; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // Community ban / timeout write-block (COMMUNITY_MODERATION_PLAN.md §0 + // decision 4). A timeout is a write-block only — the connection stays open, + // content writes are refused with `restricted: you are timed out until ` + // so the desktop can render a countdown. A ban is normally enforced at the + // auth seam, but an already-authenticated connection never re-auths: if the + // live-disconnect fan-out is missed (fire-and-forget publish, broadcast lag, + // subscriber reconnect window), a banned member's open socket would keep + // writing indefinitely. So the ban is re-checked here — this write-path gate + // is the durable backstop the fan-out's best-effort delivery relies on. + // Moderation/relay-admin commands are exempt: a restriction must never + // disarm the tools used to lift or manage it. + // + // Scope: this gate checks the *authoring* pubkey only, with no NIP-OA + // owner→agent cascade. That cascade lives at the auth seam for bans, where + // it is structural: an agent whose owner is banned can never authenticate, + // so its socket never exists to reach ingest. Timeout has no auth-seam + // presence (it is write-block-only), so an owner-timeout does not cascade to + // the owner's agents — a deliberate Phase-1 asymmetry. `IngestAuth` does not + // carry the self-proving auth tag, so resolving the owner here would mean + // plumbing it through the whole transport boundary; the follow-up shape is + // the restriction-state cache (see should-fix), which can fold in owner + // resolution without a per-write DB round-trip. + if !buzz_core::kind::is_moderation_command_kind(kind_u32) && !is_relay_admin_kind(kind_u32) { + match state + .db + .moderation_restriction_state(tenant.community(), auth.pubkey().as_bytes()) + .await + { + Ok(r) => { + if r.banned { + return Err(IngestError::AuthFailed( + "blocked: you are banned from this community".to_string(), + )); + } + if let Some(until) = r.muted_until { + if until > chrono::Utc::now() { + return Err(IngestError::AuthFailed(format!( + "restricted: you are timed out until {}", + until.timestamp() + ))); + } + } + } + Err(e) => { + // Fail closed: a DB error must not let a banned/timed-out actor + // write. + return Err(IngestError::Internal(format!( + "error: internal error checking restriction state: {e}" + ))); + } + } + } + let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(tenant.community(), &state.db, &event).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), @@ -2352,6 +2458,54 @@ mod tests { assert!(!requires_h_channel_scope(KIND_USER_STATUS)); } + #[test] + fn reports_and_moderation_commands_require_messages_write_scope() { + let dummy = make_dummy_event(); + for kind in [ + KIND_REPORT, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, + ] { + assert_eq!( + required_scope_for_kind(kind, &dummy).unwrap(), + Scope::MessagesWrite, + "kind {kind} should require MessagesWrite scope" + ); + } + } + + #[test] + fn moderation_commands_are_global_only() { + for kind in [ + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, + ] { + assert!(is_global_only_kind(kind), "kind {kind} must be global-only"); + assert!( + !requires_h_channel_scope(kind), + "kind {kind} must not require an h tag" + ); + } + } + + #[test] + fn moderation_command_rejection_from_ingest_preserves_prefix() { + let rejection = "restricted: moderator access required".to_string(); + let map_rejection = IngestError::Rejected; + let result: Result<(), String> = Err(rejection.clone()); + + match result.map_err(map_rejection).unwrap_err() { + IngestError::Rejected(message) => assert_eq!(message, rejection), + _ => panic!("expected rejected ingest error"), + } + } + #[test] fn global_only_and_channel_scoped_are_disjoint() { // A kind cannot be both global-only and channel-scoped @@ -2375,6 +2529,12 @@ mod tests { KIND_PROFILE, KIND_DELETION, KIND_REACTION, + KIND_REPORT, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, KIND_STREAM_MESSAGE, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs index 4a6f1becb..aee9bb63a 100644 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ b/crates/buzz-relay/src/handlers/mesh_signaling.rs @@ -563,9 +563,11 @@ mod tests { ) { let conn_id = uuid::Uuid::new_v4(); let (tx, rx) = tokio::sync::mpsc::channel(10); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(10); state.conn_manager.register( conn_id, tx, + ctrl_tx, tokio_util::sync::CancellationToken::new(), test_tenant().community(), std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index da47d80a1..74aef955a 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -16,8 +16,16 @@ pub mod imeta; pub mod ingest; /// Mesh hole-punch signaling: validate membership + emit paired call-me-now. pub mod mesh_signaling; +/// Community moderation authorization seam (capability helper). +pub mod moderation_authz; +/// Community moderation command handler (kinds 9040–9044). +pub mod moderation_commands; +/// Relay-signed moderation notice DMs. +pub mod moderation_notices; /// NIP-43 relay membership admin command handler (kinds 9030–9032). pub mod relay_admin; +/// NIP-56 report (kind:1984) validation + moderation queue persistence. +pub mod report; /// REQ handler — subscribe, deliver historical events, then EOSE. pub mod req; /// NIP-29 and NIP-25 side-effect handlers. diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs new file mode 100644 index 000000000..2f2781dd1 --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -0,0 +1,336 @@ +//! Community moderation authorization (Phase 1 contract). +//! +//! One capability seam for every moderation decision, per +//! `PLANS/COMMUNITY_MODERATION_PLAN.md` §0.1: roles are community +//! `owner`/`admin` (from tenant-scoped `relay_members`) plus existing +//! channel-level owner/admin. There is no Moderator tier in v1 — but all +//! authorization routes through [`authorize_moderation_action`] so adding one +//! later is a policy change, not a rewrite. +//! +//! ## Tenant invariant +//! Authority never crosses the tenant fence: the actor's role is read from +//! `relay_members` / `channel_members` under `tenant.community()` only, and +//! callers must have already resolved `target` inside the same tenant. +//! +//! Lane ownership: L2 (Mari). Signatures below are the contract. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use uuid::Uuid; + +use crate::state::AppState; + +/// A moderation capability being exercised. +/// +/// V1 capability grid (plan §4 Gap A): community owner/admin hold all of +/// these community-wide; channel owner/admin hold `DeleteMessage`/`Kick` +/// within their channel only; members hold none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModerationAction { + /// Delete any message (kind:9005 path). + DeleteMessage, + /// Remove/kick a user from a channel (kind:9001 path). + Kick, + /// Ban a user from the community (community owner/admin only). + Ban, + /// Lift a community ban. + Unban, + /// Time-box a user's writes (community owner/admin only). + Timeout, + /// Clear a timeout early. + Untimeout, + /// Resolve/dismiss/escalate reports in the moderation queue. + ResolveReport, + /// Read the moderation queue and audit log. + ViewQueue, +} + +/// What the action is aimed at (already tenant-resolved by the caller). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModerationTarget<'a> { + /// An event (32-byte id) in `channel_id`'s community. + Event(&'a [u8]), + /// A member pubkey in this community. + Pubkey(&'a [u8]), + /// No specific target (queue/audit reads). + None, +} + +/// Why an authorization succeeded — recorded in the audit row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModerationAuthority { + /// Actor is community `owner` in `relay_members`. + CommunityOwner, + /// Actor is community `admin` in `relay_members`. + CommunityAdmin, + /// Actor is channel owner/admin of the target's channel. + ChannelRole, +} + +/// Decide whether `actor` may perform `action` on `target`. +/// +/// - Community `owner`/`admin` (tenant-scoped `relay_members.role`) are +/// authorized for every [`ModerationAction`] in any channel of their +/// community — this is the bridge `validate_admin_event` is missing today. +/// - Channel owner/admin keep their existing channel-local authority for +/// `DeleteMessage`/`Kick` (via `channel_id`). +/// - Guard rails (plan): an admin cannot ban/timeout the community owner or +/// a fellow admin; only the owner can action an admin. +/// +/// Returns the matched authority for the audit row, or `Err` with a +/// client-safe denial message. +pub async fn authorize_moderation_action( + tenant: &TenantContext, + state: &Arc, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, +) -> anyhow::Result { + let community = tenant.community(); + + // Community role: `relay_members` stores pubkeys as 64-char hex, fenced to + // `community` in the query itself. This is the primary authority — owner and + // admin can moderate any channel in their community. + let actor_role = state + .db + .get_relay_member(community, &hex::encode(actor_pubkey)) + .await? + .map(|m| m.role); + + // The target's community role is read only for the admin guard rail — i.e. + // an admin actioning a pubkey with ban/timeout — so the owner and + // channel-role paths stay at a single query. + let target_role = match (actor_role.as_deref(), action, target) { + (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { + match target { + ModerationTarget::Pubkey(pk) => state + .db + .get_relay_member(community, &hex::encode(pk)) + .await? + .map(|m| m.role), + _ => None, + } + } + _ => None, + }; + + // The channel role is read only when community authority does not apply and + // the action is channel-local (DeleteMessage/Kick within `channel_id`). + let channel_role = match (actor_role.as_deref(), action, channel_id) { + (Some("owner") | Some("admin"), _, _) => None, + (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { + state + .db + .get_member_role(community, channel_id, actor_pubkey) + .await? + } + _ => None, + }; + + decide_authority( + actor_role.as_deref(), + target_role.as_deref(), + channel_role.as_deref(), + action, + ) +} + +/// Pure authorization decision from resolved roles — the policy, factored out +/// of the I/O so it is exhaustively unit-testable. +/// +/// - `actor_role` / `target_role`: community `relay_members` role, if any. +/// - `channel_role`: the actor's channel role, resolved by the caller only when +/// community authority does not apply and the action is channel-local. +fn decide_authority( + actor_role: Option<&str>, + target_role: Option<&str>, + channel_role: Option<&str>, + action: ModerationAction, +) -> anyhow::Result { + match actor_role { + // Owner holds every capability, community-wide, with no guard rail. + Some("owner") => Ok(ModerationAuthority::CommunityOwner), + // Admin holds every capability, but cannot ban/timeout the owner or a + // fellow admin — only the owner may action an admin. The guard trips only + // on a target *role* of owner/admin: a target with no `relay_members` row + // (a drive-by spammer who already left) is bannable. Unban/Untimeout lift + // a restriction and are intentionally unguarded — a banned admin can't + // self-unban (banned means blocked at the auth seam before any command + // runs), so the only reachable case is an admin lifting a fellow admin's + // restriction, which is benign, audited, and owner-reversible; guarding it + // would instead strand a wrongly-banned admin behind an owner-only unlock. + Some("admin") => { + if matches!(action, ModerationAction::Ban | ModerationAction::Timeout) + && matches!(target_role, Some("owner") | Some("admin")) + { + anyhow::bail!("an admin cannot ban or time out a community owner or fellow admin"); + } + Ok(ModerationAuthority::CommunityAdmin) + } + // Not a community owner/admin: channel owner/admin keep channel-local + // authority for DeleteMessage/Kick only. + _ => match (action, channel_role) { + ( + ModerationAction::DeleteMessage | ModerationAction::Kick, + Some("owner") | Some("admin"), + ) => Ok(ModerationAuthority::ChannelRole), + _ => anyhow::bail!("moderator access required"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every community-wide action a community owner can take. Channel-local + /// actions (DeleteMessage/Kick) are included — the owner holds them too. + const ALL_ACTIONS: [ModerationAction; 8] = [ + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::Ban, + ModerationAction::Unban, + ModerationAction::Timeout, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ]; + + fn ok(r: anyhow::Result) -> ModerationAuthority { + r.expect("expected authorization") + } + + #[test] + fn community_owner_authorized_for_everything() { + for action in ALL_ACTIONS { + // Even against another owner/admin target: the owner has no guard rail. + assert_eq!( + ok(decide_authority(Some("owner"), Some("admin"), None, action)), + ModerationAuthority::CommunityOwner, + "owner must be authorized for {action:?}" + ); + } + } + + #[test] + fn community_admin_authorized_against_non_privileged_targets() { + for action in ALL_ACTIONS { + // Target is a plain member (or unknown) — admin holds every capability. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a member" + ); + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a non-member" + ); + } + } + + #[test] + fn admin_cannot_ban_or_timeout_owner_or_fellow_admin() { + for target in ["owner", "admin"] { + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert!( + decide_authority(Some("admin"), Some(target), None, action).is_err(), + "admin must not {action:?} a community {target}" + ); + } + } + } + + #[test] + fn admin_can_ban_or_timeout_a_non_member_target() { + // A target with no `relay_members` row (e.g. a drive-by spammer who + // already left) must still be bannable — the guard trips on a privileged + // *role*, never on a missing row. + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a non-member target" + ); + // A plain member target is likewise fair game. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a plain member" + ); + } + } + + #[test] + fn admin_guard_rail_is_scoped_to_ban_and_timeout() { + // Reversals and non-restriction actions against an admin target are allowed — + // the guard rail protects against *applying* a restriction, not lifting one. + for action in [ + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert_eq!( + ok(decide_authority(Some("admin"), Some("admin"), None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} even against an admin target" + ); + } + } + + #[test] + fn channel_role_covers_only_delete_and_kick() { + for role in ["owner", "admin"] { + for action in [ModerationAction::DeleteMessage, ModerationAction::Kick] { + assert_eq!( + ok(decide_authority(None, None, Some(role), action)), + ModerationAuthority::ChannelRole, + "channel {role} must be authorized for {action:?}" + ); + } + // No community authority: channel role does NOT grant community actions. + for action in [ + ModerationAction::Ban, + ModerationAction::Timeout, + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert!( + decide_authority(None, None, Some(role), action).is_err(), + "channel {role} must NOT be authorized for community action {action:?}" + ); + } + } + } + + #[test] + fn plain_channel_member_and_stranger_are_denied() { + for action in ALL_ACTIONS { + assert!( + decide_authority(None, None, Some("member"), action).is_err(), + "channel member must be denied {action:?}" + ); + assert!( + decide_authority(None, None, None, action).is_err(), + "user with no role must be denied {action:?}" + ); + } + } +} diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs new file mode 100644 index 000000000..4a74352b4 --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -0,0 +1,727 @@ +//! Community moderation command handler (kinds 9040–9044, Phase 1 contract). +//! +//! Mirrors the NIP-43 relay-admin pattern (`relay_admin.rs`, 9030-series): +//! commands are validated + executed directly and are **never** stored as +//! regular events. Authorization goes through +//! [`crate::handlers::moderation_authz::authorize_moderation_action`] — +//! never inline role checks. +//! +//! | Kind | Operation | Side effects (all mandatory) | +//! |------|----------------|----------------------------------------------------| +//! | 9040 | Ban | `community_bans` upsert, audit row, live disconnect (L4 fanout), restriction notice DM (L5) | +//! | 9041 | Unban | ban lift, audit row | +//! | 9042 | Timeout | `muted_until` upsert, audit row, notice DM | +//! | 9043 | Untimeout | mute clear, audit row | +//! | 9044 | Resolve report | report status update, audit row, reporter notice DM; `delete`/`kick`/`ban` resolutions fan out through the existing 9005/9001 + 9040 paths | +//! +//! Targets (`p` tag pubkey, `report` tag row id) are resolved under the +//! request's `TenantContext` only. +//! +//! ## Routing (pinned — Wren contract review, 2026-07-07) +//! 9040–9044 are **community-global direct commands**, exactly like the +//! relay-admin 9030-series: route via +//! [`buzz_core::kind::is_moderation_command_kind`], list them in +//! `is_global_only_kind` so a stray `h` tag can never channel-scope them +//! (no channel membership/archive gates apply), require a fresh timestamp, +//! never store them, and reject channel-scoped API tokens. +//! +//! ## Tag vocabulary (pinned — CLI and relay must agree) +//! - 9040 ban: `["p", ]` required; optional +//! `["expiration", ]` (absent ⇒ permanent), `["reason", ]`. +//! - 9041 unban: `["p", ]`. +//! - 9042 timeout: `["p", ]` + required `["expiration", ]`; +//! optional `["reason", ]`. +//! - 9043 untimeout: `["p", ]`. +//! - 9044 resolve (pinned — thread event `86f46207`, 2026-07-07): required, +//! exactly one each: `["report", ]` (the 1984 report +//! being resolved; resolves under `tenant.community()` only), +//! `["status", resolved|dismissed]`, +//! `["action", delete|kick|ban|timeout|dismiss|escalate]` (`dismiss` pairs +//! with status `dismissed`; everything else with `resolved`). Optional +//! `["reason", ]` — audited into `moderation_actions.public_reason` +//! and relayed in the notice DM (so it must be safe for the reporter's +//! eyes; `private_reason` is mod-only and not fed by 9044 tags). Unknown +//! extra tags are ignored, not rejected +//! (forward-compat). `delete`/`kick`/`ban`/`timeout` actions fan out through +//! the existing 9005/9001 paths and the 9040/9042 handlers — no second +//! implementation. The resolution audit row records the *decision*, not the +//! enforcement, so it is prefixed `resolve:` (`resolve:ban`, `resolve:delete`, +//! …); the client's paired 9040-9043 writes the unprefixed enforcement row. +//! The `resolve:*` values are part of the DB CHECK vocabulary in migration 0006. +//! `dismiss` audits as `dismiss_report` and `escalate` as `escalate` (both +//! unprefixed — escalate must stay queryable for the platform-safety lane). +//! +//! Lane ownership: L6 (Quinn) — plus `buzz-cli` `moderation` command group. +//! The `ingest.rs` routing entries (scope map + `is_global_only_kind` + +//! direct-processing dispatch) for 9040–9044 belong to L3 (Perci): +//! coordinate, don't edit ingest.rs. + +use std::sync::Arc; + +use buzz_core::kind::{ + KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, +}; +use buzz_core::tenant::TenantContext; +use chrono::{DateTime, TimeZone, Utc}; +use nostr::Event; +use tracing::info; +use uuid::Uuid; + +use crate::handlers::moderation_authz::{ + authorize_moderation_action, ModerationAction, ModerationTarget, +}; +use crate::handlers::moderation_notices::{send_moderation_notice, ModerationNotice}; +use crate::state::AppState; +use buzz_db::moderation::NewAction; + +/// Max clock skew for a freshly-signed command (mirrors `relay_admin.rs` and +/// the NIP-42 auth freshness window). Commands are never stored, so replay of a +/// captured command is the only threat and a tight window is the mitigation. +const MAX_COMMAND_SKEW_SECS: i64 = 120; + +/// Validate and execute a moderation command (kinds 9040–9044). +/// +/// Returns a client-safe error string for `OK false` on rejection. +/// +/// Routing note: 9040–9044 are community-global direct commands (L3 lists them +/// in `is_global_only_kind`), so no `h`/channel context is consulted here; the +/// tenant is bound from the request. Authorization goes through +/// [`authorize_moderation_action`] — never inline role checks. +pub async fn handle_moderation_command( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let actor = event.pubkey.to_bytes().to_vec(); + + // Freshness: reject stale/replayed commands (they are never stored). + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + return Err(invalid(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + event_ts - now + ))); + } + + match kind { + KIND_MODERATION_BAN => handle_ban(tenant, state, event, &actor).await, + KIND_MODERATION_UNBAN => handle_unban(tenant, state, event, &actor).await, + KIND_MODERATION_TIMEOUT => handle_timeout(tenant, state, event, &actor).await, + KIND_MODERATION_UNTIMEOUT => handle_untimeout(tenant, state, event, &actor).await, + KIND_MODERATION_RESOLVE_REPORT => handle_resolve(tenant, state, event, &actor).await, + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), + } +} + +// ── 9040: ban ─────────────────────────────────────────────────────────────── + +async fn handle_ban( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + let expires_at = extract_expiration(event)?; // None ⇒ permanent + let reason = extract_tag_value(event, "reason"); + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Ban, + ) + .await + .map_err(authz_denial)?; + + state + .db + .ban_community_member( + tenant.community(), + &target, + actor, + reason.as_deref(), + expires_at, + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + + let action_id = insert_audit( + state, + tenant, + actor, + "ban", + Some(&target), + None, + reason.as_deref(), + ) + .await?; + + // Live enforcement: close open sessions for the banned principal now — + // this pod's sockets synchronously (fenced to this community) and every + // other pod's via the fire-and-forget cross-pod fan-out. The paired helper + // makes "close locally but forget the Redis publish" unrepresentable, so a + // live ban takes effect immediately, everywhere (decision 4). + state.disconnect_pubkey_clusterwide( + tenant, + &target, + &event.id.to_hex(), + "blocked: you are banned from this community", + ); + + // Notice DM: tell the banned user the terms of the restriction. + let public_reason = reason.clone().unwrap_or_default(); + if let Err(e) = send_moderation_notice( + tenant, + state, + &target, + ModerationNotice::Restriction { + action_id, + kind: "ban".to_string(), + public_reason, + }, + ) + .await + { + // Notice delivery is best-effort; the ban itself has already landed and + // been audited. Log and continue rather than fail the command. + info!(error = %e, "ban notice DM delivery failed (ban still enforced)"); + } + + info!(target = %hex::encode(&target), "community ban applied"); + Ok(()) +} + +// ── 9041: unban ────────────────────────────────────────────────────────────── + +async fn handle_unban( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Unban, + ) + .await + .map_err(authz_denial)?; + + let lifted = state + .db + .unban_community_member(tenant.community(), &target, actor) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !lifted { + return Err(invalid("member is not banned")); + } + + insert_audit(state, tenant, actor, "unban", Some(&target), None, None).await?; + + info!(target = %hex::encode(&target), "community ban lifted"); + Ok(()) +} + +// ── 9042: timeout ──────────────────────────────────────────────────────────── + +async fn handle_timeout( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + let muted_until = + extract_expiration(event)?.ok_or_else(|| invalid("timeout requires an expiration tag"))?; + let reason = extract_tag_value(event, "reason"); + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Timeout, + ) + .await + .map_err(authz_denial)?; + + state + .db + .timeout_community_member( + tenant.community(), + &target, + actor, + muted_until, + reason.as_deref(), + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + + let action_id = insert_audit( + state, + tenant, + actor, + "timeout", + Some(&target), + None, + reason.as_deref(), + ) + .await?; + + let public_reason = reason.clone().unwrap_or_default(); + if let Err(e) = send_moderation_notice( + tenant, + state, + &target, + ModerationNotice::Restriction { + action_id, + kind: "timeout".to_string(), + public_reason, + }, + ) + .await + { + info!(error = %e, "timeout notice DM delivery failed (timeout still enforced)"); + } + + info!(target = %hex::encode(&target), "community timeout applied"); + Ok(()) +} + +// ── 9043: untimeout ────────────────────────────────────────────────────────── + +async fn handle_untimeout( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Untimeout, + ) + .await + .map_err(authz_denial)?; + + let cleared = state + .db + .untimeout_community_member(tenant.community(), &target, actor) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !cleared { + return Err(invalid("member is not timed out")); + } + + insert_audit(state, tenant, actor, "untimeout", Some(&target), None, None).await?; + + info!(target = %hex::encode(&target), "community timeout cleared"); + Ok(()) +} + +// ── 9044: resolve report ───────────────────────────────────────────────────── + +async fn handle_resolve( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let report_event_id = extract_report_tag(event) + .ok_or_else(|| invalid("missing or invalid report tag (expect 64-hex event id)"))?; + let status = extract_tag_value(event, "status").ok_or_else(|| invalid("missing status tag"))?; + let action = extract_tag_value(event, "action").ok_or_else(|| invalid("missing action tag"))?; + let reason = extract_tag_value(event, "reason"); + + // Vocab is validated at build time in the SDK, but the relay must not trust + // the client: re-validate the pinned vocabulary here. + if status != "resolved" && status != "dismissed" { + return Err(invalid(format!( + "invalid status: {status} (expect resolved|dismissed)" + ))); + } + if !matches!( + action.as_str(), + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(invalid(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + ))); + } + if (action == "dismiss") != (status == "dismissed") { + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); + } + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Event(&report_event_id), + ModerationAction::ResolveReport, + ) + .await + .map_err(authz_denial)?; + + // Resolve the report row under this tenant only. The `report` tag carries + // the signed 1984 event id (pinned contract); look the row up by it. + let report = state + .db + .get_moderation_report_by_event(tenant.community(), &report_event_id) + .await + .map_err(|e| error(format!("database error: {e}")))? + .ok_or_else(|| invalid("report not found in this community"))?; + + // Don't write an audit row for a report someone else already closed. The + // DB's `WHERE status='open'` on resolve_moderation_report below is the real + // guard; this early check keeps a lost-race resolve (two mods on the same + // report) from leaving an orphan audit row behind the failed resolve. A tiny + // residual race remains — the row can flip to closed between this read and + // the DB write — but that window yields only an audit row plus a failed + // resolve, which is tolerated. + if report.status != "open" { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + + // Carry the report's own target into the audit row so `delete`/`kick`/`ban` + // resolutions record what they acted on. + let (target_pubkey, target_event_id) = match &report.target { + buzz_db::moderation::ReportTarget::Pubkey(p) => (Some(p.as_slice()), None), + buzz_db::moderation::ReportTarget::Event(e) => (None, Some(e.as_slice())), + buzz_db::moderation::ReportTarget::Blob(_) => (None, None), + }; + + // Distinguish a resolution *decision* from the actual *enforcement* row. + // A one-click resolve with action=ban records the moderator's decision; the + // client then composes the real 9040, which writes its own "ban" enforcement + // row. `resolve:*` decision rows are part of the moderation_actions DB + // vocabulary so audit consumers can tell the two apart and don't double-count. + // `dismiss_report` and `escalate` stay unprefixed — escalate especially must + // remain queryable for the platform-safety lane. + let audit_action = resolution_audit_action(&action); + let action_id = insert_audit( + state, + tenant, + actor, + audit_action, + target_pubkey, + target_event_id, + reason.as_deref(), + ) + .await?; + + let resolved = state + .db + .resolve_moderation_report( + tenant.community(), + report.id, + &status, + actor, + Some(action_id), + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !resolved { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + + // Close the loop: DM the reporter that their report was reviewed. + let summary = reason.clone().unwrap_or_else(|| match status.as_str() { + "dismissed" => "Your report was reviewed and dismissed.".to_string(), + _ => "Your report was reviewed and acted on.".to_string(), + }); + if let Err(e) = send_moderation_notice( + tenant, + state, + &report.reporter_pubkey, + ModerationNotice::ReportResolved { + report_id: report.id, + status: status.clone(), + summary, + }, + ) + .await + { + info!(error = %e, "report-resolution notice DM delivery failed (report still resolved)"); + } + + info!(report_id = %report.id, status = %status, action = %action, "report resolved"); + Ok(()) +} + +// ── shared helpers ──────────────────────────────────────────────────────────── + +fn resolution_audit_action(action: &str) -> &'static str { + match action { + "dismiss" => "dismiss_report", + "escalate" => "escalate", + "delete" => "resolve:delete", + "kick" => "resolve:kick", + "ban" => "resolve:ban", + "timeout" => "resolve:timeout", + // The caller validates this vocabulary before mapping. + _ => "resolve:unknown", + } +} + +/// Insert a moderation audit row for an accepted command. `matched_principal` +/// is left `None` here: that NIP-OA field records which principal an +/// *enforcement* check matched at the auth seam (L4), not who issued a command. +async fn insert_audit( + state: &Arc, + tenant: &TenantContext, + actor: &[u8], + action: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + public_reason: Option<&str>, +) -> Result { + state + .db + .insert_moderation_action( + tenant.community(), + NewAction { + actor_pubkey: actor, + action, + target_pubkey, + target_event_id, + channel_id: None, + reason_code: None, + public_reason, + private_reason: None, + matched_principal: None, + }, + ) + .await + .map_err(|e| error(format!("failed to write audit row: {e}"))) +} + +/// Map an authorization error to a client-safe `restricted:`-prefixed denial. +fn authz_denial(e: anyhow::Error) -> String { + format!("restricted: {e}") +} + +fn invalid(message: impl Into) -> String { + format!("invalid: {}", message.into()) +} + +fn error(message: impl Into) -> String { + format!("error: {}", message.into()) +} + +/// Extract the first valid `p` tag as raw pubkey bytes (32 bytes). +fn extract_p_tag_bytes(event: &Event) -> Option> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("p") { + if let Some(val) = parts.get(1).map(|s| s.as_str()) { + if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) { + return hex::decode(val).ok(); + } + } + } + } + None +} + +/// Extract the `report` tag as a 32-byte event id (the signed 1984 report). +fn extract_report_tag(event: &Event) -> Option> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("report") { + if let Some(val) = parts.get(1).map(|s| s.as_str()) { + if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) { + return hex::decode(val).ok(); + } + } + } + } + None +} + +/// Parse an optional `expiration` tag (unix seconds) into a UTC timestamp. +/// Returns `Ok(None)` when absent, `Err` on a malformed value. +fn extract_expiration(event: &Event) -> Result>, String> { + match extract_tag_value(event, "expiration") { + None => Ok(None), + Some(raw) => { + let secs: i64 = raw + .parse() + .map_err(|_| invalid(format!("invalid expiration tag: {raw}")))?; + match Utc.timestamp_opt(secs, 0).single() { + Some(ts) => Ok(Some(ts)), + None => Err(invalid(format!("expiration out of range: {secs}"))), + } + } + } +} + +/// Extract the value of the first tag with the given name. +fn extract_tag_value(event: &Event, name: &str) -> Option { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(name) { + return parts.get(1).map(|s| s.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a signed event with the given kind, timestamp, and tags. + fn make_event(kind: u16, created_at_secs: u64, tags: Vec>) -> Event { + let keys = Keys::generate(); + let nostr_tags: Vec = tags + .into_iter() + .map(|parts| Tag::parse(parts).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::from(kind), "") + .tags(nostr_tags) + .custom_created_at(nostr::Timestamp::from_secs(created_at_secs)) + .sign_with_keys(&keys) + .expect("signing failed") + } + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + #[test] + fn resolve_audit_actions_are_allowed_by_db_check_vocabulary() { + for action in ["dismiss", "escalate", "delete", "kick", "ban", "timeout"] { + let audit_action = resolution_audit_action(action); + assert!( + buzz_db::moderation::MODERATION_ACTION_CHECK_VOCAB.contains(&audit_action), + "9044 action={action} maps to {audit_action}, which must be accepted by migrations/0006_moderation.sql moderation_actions.action CHECK" + ); + } + } + + #[test] + fn command_error_prefix_helpers_preserve_machine_readable_token() { + assert_eq!( + authz_denial(anyhow::anyhow!("moderator access required")), + "restricted: moderator access required" + ); + assert_eq!(invalid("missing status tag"), "invalid: missing status tag"); + assert_eq!( + error("database error: connection lost"), + "error: database error: connection lost" + ); + } + + #[test] + fn extract_p_tag_bytes_valid() { + let hex = "a".repeat(64); + let e = make_event(9040, now_secs(), vec![vec!["p".into(), hex.clone()]]); + assert_eq!(extract_p_tag_bytes(&e), hex::decode(&hex).ok()); + } + + #[test] + fn extract_p_tag_bytes_rejects_short_and_nonhex() { + assert_eq!( + extract_p_tag_bytes(&make_event( + 9040, + now_secs(), + vec![vec!["p".into(), "abcd".into()]] + )), + None + ); + let bad = "g".repeat(64); + assert_eq!( + extract_p_tag_bytes(&make_event(9040, now_secs(), vec![vec!["p".into(), bad]])), + None + ); + } + + #[test] + fn extract_report_tag_requires_64_hex() { + let id = "b".repeat(64); + let e = make_event(9044, now_secs(), vec![vec!["report".into(), id.clone()]]); + assert_eq!(extract_report_tag(&e), hex::decode(&id).ok()); + // A UUID-shaped value (Wren's L5 lesson: never a UUID where an event id belongs). + let uuid = make_event( + 9044, + now_secs(), + vec![vec![ + "report".into(), + "550e8400-e29b-41d4-a716-446655440000".into(), + ]], + ); + assert_eq!(extract_report_tag(&uuid), None); + } + + #[test] + fn expiration_absent_is_none() { + let e = make_event(9040, now_secs(), vec![]); + assert_eq!(extract_expiration(&e).unwrap(), None); + } + + #[test] + fn expiration_valid_parses() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "1893456000".into()]], + ); + assert_eq!( + extract_expiration(&e).unwrap(), + Utc.timestamp_opt(1_893_456_000, 0).single() + ); + } + + #[test] + fn expiration_malformed_errs() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "not-a-number".into()]], + ); + assert!(extract_expiration(&e).is_err()); + } + + #[test] + fn expiration_out_of_range_errs() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "99999999999999".into()]], + ); + assert!(extract_expiration(&e).is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs new file mode 100644 index 000000000..0e5b51ef2 --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -0,0 +1,387 @@ +//! Relay-signed moderation notice DMs (Phase 1 contract). +//! +//! Plan §0.3 (Tyler, 2026-07-07): every resolution/action notice is a real +//! nostr message in the DB, authored by the relay moderation key: +//! +//! 1. Create/reuse the two-party DM channel `{relay mod key, user}` via the +//! participant-hash-idempotent DM model (`buzz-db/src/dm.rs`). +//! 2. Emit kind:39000 discovery with `hidden`, `t=dm`, and `p` tags. +//! 3. Insert a relay-signed kind:9 with `h=`. +//! 4. Publish a relay kind:0 profile named "{Community} Moderation". +//! +//! One DM thread per user per community. Non-replyable in v1 (replies are +//! v2 appeal routing). The same primitive carries reporter-resolution, +//! actioned-author, and timeout/ban notices. +//! +//! ## Privacy +//! Notices to an actioned author never name the reporter(s) or quote report +//! notes. Notices to a reporter never reveal other reporters. +//! +//! Lane ownership: L5 (Sami). + +use std::sync::Arc; + +use nostr::{EventBuilder, Kind, Tag}; +use tracing::warn; +use uuid::Uuid; + +use buzz_core::kind::{event_kind_u32, KIND_STREAM_MESSAGE}; +use buzz_core::tenant::TenantContext; + +use super::event::dispatch_persistent_event; +use super::side_effects::emit_group_discovery_events; +use crate::state::AppState; + +/// Tag naming the moderation source row (report/action) a notice was derived +/// from. Deliberately non-standard: `e` is reserved for 32-byte event ids, but +/// the source is an opaque DB row UUID. Used for idempotency and client linking. +const MODERATION_SOURCE_TAG: &str = "moderation_source"; + +/// Which notice is being delivered — determines template + audience. +#[derive(Debug, Clone)] +pub enum ModerationNotice { + /// To a reporter: their report was reviewed; outcome summary. + ReportResolved { + /// The resolved report row. + report_id: Uuid, + /// `resolved` | `dismissed`. + status: String, + /// Sanitized outcome line (no reporter/mod identities beyond policy). + summary: String, + }, + /// To an actioned author: which message, which rule, what happened. + ContentActioned { + /// The audit action row. + action_id: Uuid, + /// Sanitized reason (mirrors the tombstone's `public_reason`). + public_reason: String, + }, + /// To a banned/timed-out user: terms of the restriction. + Restriction { + /// The audit action row. + action_id: Uuid, + /// `ban` | `timeout` (with expiry rendered into the message). + kind: String, + /// Sanitized reason. + public_reason: String, + }, +} + +/// Deliver a moderation notice to `recipient` in this community's +/// relay-authored DM thread (created on first use, reused after). +/// +/// Crash-retry safe per (action/report id, recipient): a retry after a +/// committed insert is a no-op; concurrent duplicate sends are not serialized +/// in v1. +pub async fn send_moderation_notice( + tenant: &TenantContext, + state: &Arc, + recipient_pubkey: &[u8], + notice: ModerationNotice, +) -> anyhow::Result<()> { + if recipient_pubkey.len() != 32 { + anyhow::bail!( + "moderation notice recipient must be a 32-byte pubkey, got {}", + recipient_pubkey.len() + ); + } + let relay_pubkey = state.relay_keypair.public_key(); + let relay_pubkey_bytes = relay_pubkey.to_bytes(); + let relay_pubkey_hex = hex::encode(relay_pubkey_bytes); + + // Never DM the relay key itself (would create a self-DM and is meaningless). + if recipient_pubkey == relay_pubkey_bytes.as_slice() { + return Ok(()); + } + + // 1. Create/reuse the two-party DM channel {relay mod key, recipient}. + // `open_dm` is participant-hash idempotent, so re-delivery to the same + // user reuses the one thread per (community, user). + let (dm_channel, _was_created) = state + .db + .open_dm( + tenant.community(), + &[recipient_pubkey], + relay_pubkey_bytes.as_slice(), + ) + .await?; + let dm_channel_id = dm_channel.id; + + // Resurface the moderation DM for the recipient. `open_dm` only clears + // `hidden_at` for `created_by` (the relay key), so a user who hid the + // "{host} Moderation" thread would never see a later ban/resolution notice. + // The closed-loop trust requirement needs the notice to reappear. + state + .db + .unhide_dm(tenant.community(), dm_channel_id, recipient_pubkey) + .await?; + + // Idempotency: a notice for this source id already exists in this DM ⇒ no-op. + // The source (report/action) row id is carried in a `moderation_source` tag + // (NOT `e` — `e` is reserved for 32-byte event ids; this is an opaque row + // UUID). Keyed on it, a retry after a crash between insert and fan-out is a + // safe no-op. Note: this is query-then-insert, so it is crash-retry safe but + // not concurrency-safe — two simultaneous deliveries for the same source can + // both miss the pre-query. Callers invoke this once per action from + // already-serialized side-effect paths; hard per-source serialization is a + // noted follow-up, not done here. + let source_id = notice.source_id(); + if notice_already_sent(state, tenant, dm_channel_id, &relay_pubkey_bytes, source_id).await? { + return Ok(()); + } + + // 2. Ensure the relay's "{host} Moderation" kind:0 profile exists, and 3. + // the DM's kind:39000 discovery (with `hidden` / `t=dm` / `p`). Both are + // replaceable events, so we emit them on EVERY send rather than gating on + // first creation: if discovery failed on the first delivery (it is + // `?`-propagated), a `was_created`-gated retry would skip it forever and + // leave the thread permanently undiscoverable — a notice delivered into a + // channel no client can render. Notices are rare; unconditional re-emit is + // cheap and `replace_addressable_event` makes it idempotent. + if let Err(e) = publish_moderation_profile(tenant, state, &relay_pubkey_hex).await { + warn!(error = %e, "moderation profile publish failed (continuing)"); + } + emit_group_discovery_events(tenant, state, dm_channel_id).await?; + + // 4. Insert the relay-signed kind:9 notice with `h=` and a + // `moderation_source` tag naming the source row id (idempotency + + // client linking). + let tags = vec![ + Tag::parse(["h", &dm_channel_id.to_string()])?, + Tag::parse([MODERATION_SOURCE_TAG, &source_id.to_string()])?, + ]; + let event = EventBuilder::new( + Kind::Custom(KIND_STREAM_MESSAGE as u16), + notice.body(tenant), + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign moderation notice: {e}"))?; + + let (stored, _inserted) = state + .db + .insert_event(tenant.community(), &event, Some(dm_channel_id)) + .await?; + + let kind_u32 = event_kind_u32(&stored.event); + dispatch_persistent_event(tenant, state, &stored, kind_u32, &relay_pubkey_hex, None).await; + + Ok(()) +} + +/// Publish the relay-signed kind:0 "{host} Moderation" profile so clients can +/// render the DM author with a recognizable name. Replaceable (NIP-01), so +/// re-emitting is idempotent — the latest wins. +async fn publish_moderation_profile( + tenant: &TenantContext, + state: &Arc, + relay_pubkey_hex: &str, +) -> anyhow::Result<()> { + let name = format!("{} Moderation", tenant.host()); + let metadata = serde_json::json!({ + "name": name, + "display_name": name, + "about": "Automated notices about moderation actions in this community. \ + Replies are not monitored.", + }); + let event = EventBuilder::new(Kind::Metadata, metadata.to_string()) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign moderation profile: {e}"))?; + + // kind:0 is a replaceable event; store globally (channel_id = None) like + // every other user profile so it is resolvable by any client. + let (stored, was_inserted) = state + .db + .replace_addressable_event(tenant.community(), &event, None) + .await?; + if was_inserted { + let kind_u32 = event_kind_u32(&stored.event); + dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; + } + Ok(()) +} + +/// True if a relay-authored notice for `source_id` already exists in this DM. +/// +/// Idempotency scan scoped to the recipient's single moderation DM thread +/// (kind:9, relay-authored) — bounded by that user's own notice history, so no +/// unbounded read. Matches the opaque `moderation_source` tag in Rust because +/// `EventQuery` only pushes down standardized `e`/`d`/`p` tags and this row id +/// is intentionally not an `e` tag (see `MODERATION_SOURCE_TAG`). +/// +/// `limit` is set to the query clamp (1000): matching is post-query in Rust so +/// `Some(1)` would be wrong, and the default 100-row window could let an old +/// source id fall out of view and re-send a duplicate on crash-retry. 1000 +/// moderation notices to one user in one community is a practical ceiling. +async fn notice_already_sent( + state: &Arc, + tenant: &TenantContext, + dm_channel_id: Uuid, + relay_pubkey_bytes: &[u8], + source_id: Uuid, +) -> anyhow::Result { + let existing = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_STREAM_MESSAGE as i32]), + channel_id: Some(dm_channel_id), + authors: Some(vec![relay_pubkey_bytes.to_vec()]), + limit: Some(1000), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + + let source_str = source_id.to_string(); + Ok(existing.iter().any(|stored| { + stored.event.tags.iter().any(|t| { + let parts = t.as_slice(); + parts.len() >= 2 && parts[0] == MODERATION_SOURCE_TAG && parts[1] == source_str + }) + })) +} + +impl ModerationNotice { + /// The source row id this notice is derived from — the idempotency key and + /// the `moderation_source` tag value that lets a client link the notice back + /// to its action. + fn source_id(&self) -> Uuid { + match self { + ModerationNotice::ReportResolved { report_id, .. } => *report_id, + ModerationNotice::ContentActioned { action_id, .. } => *action_id, + ModerationNotice::Restriction { action_id, .. } => *action_id, + } + } + + /// Render the recipient-facing message body. + /// + /// Privacy invariant (module docs): these strings are built only from the + /// notice's own sanitized fields — a report/action status, a summary, and a + /// `public_reason` that already mirrors the tombstone. They never carry + /// reporter identities, other reporters, or raw report notes. + fn body(&self, tenant: &TenantContext) -> String { + let community = tenant.host(); + match self { + ModerationNotice::ReportResolved { + status, summary, .. + } => { + let outcome = match status.as_str() { + "resolved" => "was reviewed and acted on", + "dismissed" => "was reviewed; no action was taken", + "escalated" => "was escalated for further review", + other => other, + }; + format!( + "Thanks for your report to {community}. Your report {outcome}.\n\n{summary}" + ) + } + ModerationNotice::ContentActioned { public_reason, .. } => { + format!( + "A moderator in {community} took action on your content.\n\nReason: {public_reason}" + ) + } + ModerationNotice::Restriction { + kind, + public_reason, + .. + } => { + let action = match kind.as_str() { + "ban" => "You have been banned from", + "timeout" => "You have been timed out in", + other => other, + }; + format!("{action} {community}.\n\nReason: {public_reason}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tenant() -> TenantContext { + TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + "example.org", + ) + } + + #[test] + fn source_id_selects_the_right_field() { + let report = Uuid::new_v4(); + let action = Uuid::new_v4(); + assert_eq!( + ModerationNotice::ReportResolved { + report_id: report, + status: "resolved".into(), + summary: String::new(), + } + .source_id(), + report + ); + assert_eq!( + ModerationNotice::ContentActioned { + action_id: action, + public_reason: String::new(), + } + .source_id(), + action + ); + assert_eq!( + ModerationNotice::Restriction { + action_id: action, + kind: "ban".into(), + public_reason: String::new(), + } + .source_id(), + action + ); + } + + #[test] + fn report_resolved_body_reflects_status_and_never_leaks_reporter() { + let t = tenant(); + let body = ModerationNotice::ReportResolved { + report_id: Uuid::new_v4(), + status: "dismissed".into(), + summary: "The message did not violate community rules.".into(), + } + .body(&t); + assert!(body.contains("example.org")); + assert!(body.contains("no action was taken")); + assert!(body.contains("did not violate")); + } + + #[test] + fn restriction_body_distinguishes_ban_from_timeout() { + let t = tenant(); + let ban = ModerationNotice::Restriction { + action_id: Uuid::new_v4(), + kind: "ban".into(), + public_reason: "Repeated spam.".into(), + } + .body(&t); + assert!(ban.contains("banned from example.org")); + assert!(ban.contains("Repeated spam.")); + + let timeout = ModerationNotice::Restriction { + action_id: Uuid::new_v4(), + kind: "timeout".into(), + public_reason: "Cool off.".into(), + } + .body(&t); + assert!(timeout.contains("timed out in example.org")); + } + + #[test] + fn content_actioned_body_carries_only_the_public_reason() { + let t = tenant(); + let body = ModerationNotice::ContentActioned { + action_id: Uuid::new_v4(), + public_reason: "Off-topic.".into(), + } + .body(&t); + assert!(body.contains("took action on your content")); + assert!(body.contains("Off-topic.")); + } +} diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs new file mode 100644 index 000000000..fccf8eb42 --- /dev/null +++ b/crates/buzz-relay/src/handlers/report.rs @@ -0,0 +1,337 @@ +//! NIP-56 report (kind:1984) validation + persistence (Phase 1 contract). +//! +//! Reports are signals, never triggers (NIP-56): the relay persists them to +//! the tenant-scoped moderation queue and **never** auto-actions or fans them +//! out publicly. +//! +//! ## The pinned invariant (MOD, `docs/spec/MultiTenantRelay.tla`) +//! Report targets resolve under `tenant.community()` **only**: +//! - `e` target → event row looked up in this tenant; infer `channel_id` +//! from it. Not found in-tenant ⇒ reject (never search other tenants). +//! - `x` blob target → tenant-scoped media reference `(community_id, sha256)`. +//! A bare SHA-256 is shared across tenants and must not grant cross-tenant +//! visibility. +//! - `p`-only target → community-local report about that pubkey in this +//! tenant; implies nothing platform/global. +//! +//! Lane ownership: L3 (Perci) — including the `required_scope_for_kind` / +//! storage-suppression wiring in `ingest.rs`. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use buzz_db::moderation::{NewReport, ReportTarget}; +use nostr::Event; + +use crate::state::AppState; + +/// NIP-56 report types accepted at ingest. +pub const REPORT_TYPES: &[&str] = &[ + "illegal", + "nudity", + "malware", + "spam", + "impersonation", + "profanity", + "other", +]; + +/// Validate a kind:1984 report and persist it to `moderation_reports`. +/// +/// Rejections use client-safe `invalid:`/`restricted:` reasons. On success +/// the report is queued (idempotently, keyed by the signed event id) and the +/// event itself is **not** stored or broadcast as a regular event. +pub async fn handle_report_event( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result<(), String> { + let parsed = parse_report(event)?; + let reporter_pubkey = event.pubkey.to_bytes(); + + let (target, channel_id) = match parsed.target { + ParsedReportTarget::Event { event_id, .. } => { + let stored = state + .db + .get_event_by_id(tenant.community(), &event_id) + .await + .map_err(|e| format!("error: database error resolving report target: {e}"))? + .ok_or_else(|| "invalid: report target event not found".to_string())?; + (ReportTarget::Event(event_id), stored.channel_id) + } + ParsedReportTarget::Blob { sha256, .. } => { + let sha_hex = hex::encode(&sha256); + // Known Phase-1 limitation: the media sidecar API does not expose a + // cheap typed not-found vs transient-storage distinction here, so + // all lookup failures surface as a missing blob to the reporter. + state + .media_storage + .get_sidecar(tenant, &sha_hex) + .await + .map_err(|_| "invalid: report target blob not found".to_string())?; + (ReportTarget::Blob(sha256), None) + } + ParsedReportTarget::Pubkey { pubkey } => (ReportTarget::Pubkey(pubkey), None), + }; + + state + .db + .insert_moderation_report( + tenant.community(), + NewReport { + report_event_id: event.id.as_bytes(), + reporter_pubkey: &reporter_pubkey, + target, + channel_id, + report_type: parsed.report_type, + note: report_note(event), + }, + ) + .await + .map_err(|e| format!("error: database error inserting report: {e}"))?; + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedReport<'a> { + target: ParsedReportTarget, + report_type: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedReportTarget { + Event { + event_id: Vec, + /// Validation-shape only: NIP-56 requires the reported pubkey tag, but + /// e-target author truth comes from the stored tenant event row. + author_pubkey: Vec, + }, + Blob { + sha256: Vec, + /// Validation-shape only: NIP-56 requires the reported pubkey tag, but + /// blob authorship is not trusted from the reporter or inserted in v1. + author_pubkey: Vec, + }, + Pubkey { + pubkey: Vec, + }, +} + +fn parse_report(event: &Event) -> Result, String> { + let p_tags = collect_report_tags(event, "p"); + if p_tags.is_empty() { + return Err("invalid: report must include a p tag".to_string()); + } + if p_tags.len() > 1 { + return Err("invalid: report must include exactly one p tag".to_string()); + } + + let reported_pubkey = decode_32_byte_hex(p_tags[0].value, "p tag pubkey")?; + + let e_tags = collect_report_tags(event, "e"); + let x_tags = collect_report_tags(event, "x"); + if !e_tags.is_empty() && !x_tags.is_empty() { + return Err("invalid: report must target only one of e or x".to_string()); + } + if e_tags.len() > 1 { + return Err("invalid: report must include at most one e tag".to_string()); + } + if x_tags.len() > 1 { + return Err("invalid: report must include at most one x tag".to_string()); + } + + if let Some(tag) = e_tags.first() { + let report_type = parse_report_type(tag.report_type)?; + return Ok(ParsedReport { + target: ParsedReportTarget::Event { + event_id: decode_32_byte_hex(tag.value, "e tag event id")?, + author_pubkey: reported_pubkey, + }, + report_type, + }); + } + + if let Some(tag) = x_tags.first() { + let report_type = parse_report_type(tag.report_type)?; + return Ok(ParsedReport { + target: ParsedReportTarget::Blob { + sha256: decode_32_byte_hex(tag.value, "x tag sha256")?, + author_pubkey: reported_pubkey, + }, + report_type, + }); + } + + let p_tag = p_tags[0]; + let report_type = parse_report_type(p_tag.report_type)?; + Ok(ParsedReport { + target: ParsedReportTarget::Pubkey { + pubkey: reported_pubkey, + }, + report_type, + }) +} + +#[derive(Debug, Clone, Copy)] +struct ReportTag<'a> { + value: &'a str, + report_type: Option<&'a str>, +} + +fn collect_report_tags<'a>(event: &'a Event, tag_name: &str) -> Vec> { + event + .tags + .iter() + .filter_map(|tag| { + let fields = tag.as_slice(); + if fields.len() >= 2 && fields[0] == tag_name { + Some(ReportTag { + value: fields[1].as_str(), + report_type: fields.get(2).map(String::as_str), + }) + } else { + None + } + }) + .collect() +} + +fn parse_report_type(value: Option<&str>) -> Result<&str, String> { + let Some(value) = value else { + return Err("invalid: report target tag missing report type".to_string()); + }; + if REPORT_TYPES.contains(&value) { + Ok(value) + } else { + Err(format!("invalid: unsupported report type: {value}")) + } +} + +fn decode_32_byte_hex(value: &str, label: &str) -> Result, String> { + if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("invalid: malformed {label}")); + } + let bytes = hex::decode(value).map_err(|_| format!("invalid: malformed {label}"))?; + if bytes.len() != 32 { + return Err(format!("invalid: malformed {label}")); + } + Ok(bytes) +} + +fn report_note(event: &Event) -> Option<&str> { + if event.content.is_empty() { + None + } else { + Some(event.content.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::kind::KIND_REPORT; + + fn report_with_tags(tags: &[&[&str]]) -> Event { + let keys = nostr::Keys::generate(); + let tags = tags + .iter() + .map(|tag| nostr::Tag::parse(tag.iter().copied()).unwrap()) + .collect::>(); + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_REPORT as u16), "note") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn hex32(byte: u8) -> String { + hex::encode([byte; 32]) + } + + #[test] + fn parses_event_report_type_from_e_tag_third_element() { + let e = hex32(1); + let p = hex32(2); + let event = report_with_tags(&[&["p", &p], &["e", &e, "spam"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "spam"); + assert_eq!( + parsed.target, + ParsedReportTarget::Event { + event_id: vec![1; 32], + author_pubkey: vec![2; 32], + } + ); + } + + #[test] + fn parses_blob_report_type_from_x_tag_third_element() { + let x = hex32(3); + let p = hex32(4); + let event = report_with_tags(&[&["p", &p], &["x", &x, "malware"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "malware"); + assert_eq!( + parsed.target, + ParsedReportTarget::Blob { + sha256: vec![3; 32], + author_pubkey: vec![4; 32], + } + ); + } + + #[test] + fn parses_pubkey_only_report_type_from_p_tag_third_element() { + let p = hex32(5); + let event = report_with_tags(&[&["p", &p, "impersonation"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "impersonation"); + assert_eq!( + parsed.target, + ParsedReportTarget::Pubkey { + pubkey: vec![5; 32] + } + ); + } + + #[test] + fn rejects_reports_without_p_tag() { + let e = hex32(1); + let event = report_with_tags(&[&["e", &e, "spam"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: report must include a p tag" + ); + } + + #[test] + fn rejects_unknown_report_type() { + let p = hex32(1); + let event = report_with_tags(&[&["p", &p, "phishing"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: unsupported report type: phishing" + ); + } + + #[test] + fn rejects_event_and_blob_targets_together() { + let p = hex32(1); + let e = hex32(2); + let x = hex32(3); + let event = report_with_tags(&[&["p", &p], &["e", &e, "spam"], &["x", &x, "spam"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: report must target only one of e or x" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 5c898ea1e..a02324669 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -14,7 +14,7 @@ use buzz_core::kind::{ KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; -use buzz_db::channel::MemberRole; +use buzz_db::channel::{MemberRecord, MemberRole}; use super::event::dispatch_persistent_event; use crate::protocol::RelayMessage; @@ -529,6 +529,11 @@ pub async fn validate_admin_event( } 9005 => { // DELETE_EVENT: event author OR channel owner/admin. + if let Some(action_id) = extract_tag_value(event, "action_id") { + Uuid::parse_str(&action_id) + .map_err(|_| anyhow::anyhow!("invalid action_id tag"))?; + } + // Extract target event from e tag to check authorship. let target_id = event .tags @@ -567,7 +572,7 @@ pub async fn validate_admin_event( // For relay-signed REST messages, the real author is in the p tag. let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - if author == actor_bytes { + if author_delete_can_use_self_delete_path(&author, &actor_bytes, event) { // Author deleting their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. let is_member = state @@ -591,23 +596,21 @@ pub async fn validate_admin_event( // Not the author, or author who is no longer a member of a private channel — // must be owner/admin or the owning human of the message's agent-author. let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), - _ => { - // Allow the owning human of the agent that authored the target message, - // even when the human is not a channel member. - if state - .db - .is_agent_owner(tenant.community(), &author, &actor_bytes) - .await? - { - Ok(()) - } else { - Err(anyhow::anyhow!( - "must be event author or channel owner/admin" - )) - } + if actor_is_channel_owner_or_admin(&members, &actor_bytes) { + Ok(()) + } else { + // Allow the owning human of the agent that authored the target message, + // even when the human is not a channel member. + if state + .db + .is_agent_owner(tenant.community(), &author, &actor_bytes) + .await? + { + Ok(()) + } else { + Err(anyhow::anyhow!( + "must be event author or channel owner/admin" + )) } } } @@ -1607,17 +1610,16 @@ async fn handle_delete_event_side_effect( } let actor_hex = hex::encode(event.pubkey.to_bytes()); - emit_system_message( - tenant, - state, - channel_id, - serde_json::json!({ - "type": "message_deleted", - "actor": actor_hex, - "target_event_id": hex::encode(&target_id), - }), - ) - .await?; + let mut tombstone = serde_json::json!({ + "type": "message_deleted", + "actor": actor_hex, + "target_event_id": hex::encode(&target_id), + }); + copy_optional_string_field(event, &mut tombstone, "action_id"); + copy_optional_string_field(event, &mut tombstone, "reason_code"); + copy_optional_string_field(event, &mut tombstone, "public_reason"); + + emit_system_message(tenant, state, channel_id, tombstone).await?; info!(target_event = %hex::encode(&target_id), "NIP-29 DELETE_EVENT processed"); Ok(()) @@ -2264,6 +2266,60 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option { None } +fn copy_optional_string_field(event: &Event, object: &mut serde_json::Value, tag_name: &str) { + let Some(value) = extract_tag_value(event, tag_name) else { + return; + }; + copy_optional_string_value(object, tag_name, value); +} + +fn copy_optional_string_value(object: &mut serde_json::Value, field_name: &str, value: String) { + if let Some(map) = object.as_object_mut() { + map.insert(field_name.to_string(), serde_json::Value::String(value)); + } +} + +fn has_moderation_delete_metadata(event: &Event) -> bool { + ["action_id", "reason_code", "public_reason"] + .iter() + .any(|tag_name| extract_tag_value(event, tag_name).is_some()) +} + +fn author_delete_can_use_self_delete_path(author: &[u8], actor: &[u8], event: &Event) -> bool { + author == actor && !has_moderation_delete_metadata(event) +} + +fn actor_is_channel_owner_or_admin(members: &[MemberRecord], actor: &[u8]) -> bool { + members + .iter() + .any(|m| m.pubkey == actor && (m.role == "owner" || m.role == "admin")) +} + +#[cfg(test)] +fn delete_tombstone_content( + actor_hex: String, + target_event_id: String, + action_id: Option, + reason_code: Option, + public_reason: Option, +) -> serde_json::Value { + let mut tombstone = serde_json::json!({ + "type": "message_deleted", + "actor": actor_hex, + "target_event_id": target_event_id, + }); + if let Some(action_id) = action_id { + copy_optional_string_value(&mut tombstone, "action_id", action_id); + } + if let Some(reason_code) = reason_code { + copy_optional_string_value(&mut tombstone, "reason_code", reason_code); + } + if let Some(public_reason) = public_reason { + copy_optional_string_value(&mut tombstone, "public_reason", public_reason); + } + tombstone +} + /// Validate a git repo identifier (d-tag value from kind:30617). /// /// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. @@ -3089,3 +3145,86 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { None => EventTopic::Global, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delete_tombstone_omits_absent_moderation_metadata() { + let content = + delete_tombstone_content("actor".to_string(), "target".to_string(), None, None, None); + + assert_eq!(content["type"], "message_deleted"); + assert_eq!(content["actor"], "actor"); + assert_eq!(content["target_event_id"], "target"); + assert!(content.get("action_id").is_none()); + assert!(content.get("reason_code").is_none()); + assert!(content.get("public_reason").is_none()); + } + + #[test] + fn delete_tombstone_carries_optional_moderation_metadata() { + let content = delete_tombstone_content( + "actor".to_string(), + "target".to_string(), + Some("550e8400-e29b-41d4-a716-446655440000".to_string()), + Some("spam".to_string()), + Some("Removed for spam.".to_string()), + ); + + assert_eq!(content["type"], "message_deleted"); + assert_eq!(content["actor"], "actor"); + assert_eq!(content["target_event_id"], "target"); + assert_eq!(content["action_id"], "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(content["reason_code"], "spam"); + assert_eq!(content["public_reason"], "Removed for spam."); + assert!(!content.to_string().contains("reporter")); + } + + #[test] + fn author_self_delete_with_moderation_metadata_skips_self_delete_path() { + let keys = nostr::Keys::generate(); + let actor = keys.public_key().to_bytes(); + let event = EventBuilder::new(Kind::Custom(9005), "") + .tags([Tag::parse(["public_reason", "Removed for spam."]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + + assert!(!author_delete_can_use_self_delete_path( + &actor, &actor, &event + )); + } + + #[test] + fn member_role_is_not_owner_or_admin_for_moderation_metadata() { + let channel_id = Uuid::new_v4(); + let actor = vec![7_u8; 32]; + let members = vec![MemberRecord { + channel_id, + pubkey: actor.clone(), + role: "member".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }]; + + assert!(!actor_is_channel_owner_or_admin(&members, &actor)); + } + + #[test] + fn admin_role_is_owner_or_admin_for_moderation_metadata() { + let channel_id = Uuid::new_v4(); + let actor = vec![7_u8; 32]; + let members = vec![MemberRecord { + channel_id, + pubkey: actor.clone(), + role: "admin".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }]; + + assert!(actor_is_channel_owner_or_admin(&members, &actor)); + } +} diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 973b7f68b..083739bf3 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -273,6 +273,12 @@ async fn main() -> anyhow::Result<()> { let pubsub_for_cache = Arc::clone(&pubsub); tokio::spawn(async move { pubsub_for_cache.run_cache_invalidation_subscriber().await }); + // Spawn Redis pub/sub subscriber for cross-pod connection-control commands. + // Bans recorded on other pods are received here and applied to any local + // sockets (via the consumer loop below), enforcing live disconnect fan-out. + let pubsub_for_conn_ctrl = Arc::clone(&pubsub); + tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); + let auth = AuthService::new(config.auth.clone()); // Postgres FTS: the searchable row IS the persisted event row (its @@ -730,6 +736,45 @@ async fn main() -> anyhow::Result<()> { }); } + // Cross-pod connection-control consumer: receive disconnect commands from + // Redis pub/sub (published by the pod that recorded a ban) and close any + // matching local sockets. A member's live connections may land on any pod, + // so this is how a ban reaches sockets the banning pod does not hold. The DB + // ban row is the durable backstop; even a dropped command still refuses the + // banned member's next auth attempt at the auth seam. + { + let state_for_conn_ctrl = Arc::clone(&state); + let mut rx = state_for_conn_ctrl.pubsub.subscribe_conn_control(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(scoped) => match scoped.command { + buzz_pubsub::conn_control::ConnControl::DisconnectPubkey { + pubkey, + event_id, + reason, + } => { + state_for_conn_ctrl.conn_manager.disconnect_pubkey( + scoped.community_id, + &pubkey, + &event_id, + &reason, + ); + } + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("buzz_conn_control_lag_total").increment(n); + tracing::warn!("Connection-control consumer lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::error!("Connection-control broadcast channel closed"); + break; + } + } + } + }); + } + let router = build_router(Arc::clone(&state)); let health_router = build_health_router(Arc::clone(&state)); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 03e0065ef..32e178b16 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -12,7 +12,7 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES; /// /// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`] /// only when membership enforcement is actually enabled — see that function. -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50]; +pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]; /// NIP-43 (relay membership). Advertised only when the relay actually /// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a @@ -281,6 +281,14 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip56() { + assert!( + SUPPORTED_NIPS.contains(&56), + "NIP-56 (reporting) must be advertised — kind:1984 ingest is live" + ); + } + #[test] fn build_advertises_buzz_repository_url() { let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index fc9e1ec38..44565ae76 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -60,6 +60,13 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) + .route("/moderation/reports", get(api::bridge::moderation_reports)) + .route("/moderation/audit", get(api::bridge::moderation_audit)) + .route( + "/moderation/restricted", + get(api::bridge::moderation_restricted), + ) // Webhook trigger (secret-authenticated, no NIP-98) .route("/hooks/{id}", post(api::bridge::workflow_webhook)) // Huddle audio WebSocket route diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index a674e6d7c..ce955524f 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -20,6 +20,7 @@ use buzz_core::CommunityId; use buzz_db::Db; use buzz_media::MediaStorage; use buzz_pubsub::cache_invalidation::CacheInvalidation; +use buzz_pubsub::conn_control::ConnControl; use buzz_pubsub::{PubSubManager, RedisNip98ReplayGuard}; use buzz_search::SearchService; use buzz_workflow::WorkflowEngine; @@ -33,6 +34,10 @@ use crate::subscription::SubscriptionRegistry; /// Per-connection entry in the connection manager. struct ConnEntry { tx: mpsc::Sender, + /// Control-frame sender, drained ahead of data and before cancel wins in + /// the send loop. Used to deliver a ban-disconnect frame that must reach + /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). + ctrl_tx: mpsc::Sender, cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the /// receiver-side tenant label fan-out must compare against the event label. @@ -68,6 +73,7 @@ impl ConnectionManager { &self, conn_id: Uuid, tx: mpsc::Sender, + ctrl_tx: mpsc::Sender, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, @@ -78,6 +84,7 @@ impl ConnectionManager { conn_id, ConnEntry { tx, + ctrl_tx, cancel, community_id, backpressure_count, @@ -129,6 +136,50 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Disconnect every live connection authenticated as `pubkey` **in + /// `community`**, delivering a final `OK false` frame carrying `reason` + /// before closing. + /// + /// Used for live ban enforcement (COMMUNITY_MODERATION_PLAN.md §0 decision + /// 4): a ban must take effect immediately on existing sessions, not just at + /// the next auth. The frame is sent on the control channel, which the send + /// loop drains ahead of both queued data and the biased cancel branch, so + /// the client learns *why* it was dropped. `event_id` labels the `OK` (the + /// ban has no triggering client event, so a synthetic all-zero id is used). + /// + /// The `community` filter is the tenant fence: one pod holds sockets for + /// many communities, and the same pubkey may be live in several. A ban in + /// community A must close only A's sockets, never a session the member holds + /// in community B ("authority stays inside the tenant fence"). + /// + /// Returns the number of connections closed. This is the pod-local half of + /// live enforcement; cross-pod fan-out publishes the same intent over Redis. + pub fn disconnect_pubkey( + &self, + community: CommunityId, + pubkey: &[u8], + event_id: &str, + reason: &str, + ) -> usize { + let frame = crate::protocol::RelayMessage::ok(event_id, false, reason); + let mut closed = 0usize; + for conn_id in self.connection_ids_for_pubkey(pubkey) { + if let Some(entry) = self.connections.get(&conn_id) { + if entry.community_id != community { + continue; + } + // Best-effort delivery: a full control buffer still gets the + // close via cancel below, just without the reason frame. + let _ = entry + .ctrl_tx + .try_send(WsMessage::Text(frame.clone().into())); + entry.cancel.cancel(); + closed += 1; + } + } + closed + } + /// Return the server-resolved community that the connection's host bound to. pub fn community_for_conn(&self, conn_id: Uuid) -> Option { self.connections @@ -606,6 +657,53 @@ impl AppState { } } + /// Enforce a live ban cluster-wide: close this pod's sockets for `pubkey` + /// now (fenced to `tenant`'s community) and fan the same disconnect out to + /// every other pod over the conn-control Redis channel. + /// + /// This is the single entry point for live ban enforcement (decision 4: + /// "a ban takes effect immediately, everywhere, including live sessions"). + /// Callers must not invoke the pod-local `conn_manager.disconnect_pubkey` + /// directly — doing so closes sockets only on the pod that processed the + /// ban and silently drops the cluster-wide half. Pairing both halves here + /// makes that mistake unrepresentable. + /// + /// Returns the number of sockets closed on *this* pod only — remote pods + /// close asynchronously and do not report back, so callers must not treat + /// the count as cluster-wide truth. The cross-pod publish is fire-and-forget + /// (mirrors [`Self::spawn_cache_invalidation`]): the DB ban row is the + /// durable backstop, so a dropped publish still refuses the banned member's + /// next auth and next write. + pub fn disconnect_pubkey_clusterwide( + &self, + tenant: &TenantContext, + pubkey: &[u8], + event_id: &str, + reason: &str, + ) -> usize { + let closed = + self.conn_manager + .disconnect_pubkey(tenant.community(), pubkey, event_id, reason); + + // The banning pod re-receives its own publish through the subscriber and + // no-ops (its local sockets are already closed above) — intentional; do + // not add origin-suppression, it buys nothing. + let pubsub = Arc::clone(&self.pubsub); + let tenant = tenant.clone(); + let command = ConnControl::DisconnectPubkey { + pubkey: pubkey.to_vec(), + event_id: event_id.to_string(), + reason: reason.to_string(), + }; + tokio::spawn(async move { + if let Err(e) = pubsub.publish_conn_control(&tenant, &command).await { + tracing::warn!("Failed to publish conn-control disconnect: {e}"); + } + }); + + closed + } + /// Get accessible channel IDs with a 10-second cache. Falls back to DB on miss. pub async fn get_accessible_channel_ids_cached( &self, @@ -744,36 +842,40 @@ mod tests { use tokio::sync::{Mutex, RwLock}; /// Helper: create a ConnectionManager with one registered connection. - /// Returns (manager, conn_id, receiver, cancel, shared_backpressure_count). + /// Returns (manager, conn_id, receiver, ctrl_receiver, cancel, + /// shared_backpressure_count). fn setup_conn( buffer_size: usize, ) -> ( ConnectionManager, Uuid, mpsc::Receiver, + mpsc::Receiver, CancellationToken, Arc, ) { let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(buffer_size); + let (ctrl_tx, ctrl_rx) = mpsc::channel(buffer_size); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); mgr.register( conn_id, tx, + ctrl_tx, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), 3, ); - (mgr, conn_id, rx, cancel, bp) + (mgr, conn_id, rx, ctrl_rx, cancel, bp) } #[test] fn send_to_resets_grace_counter_on_success() { - let (mgr, id, _rx, _cancel, bp) = setup_conn(16); + let (mgr, id, _rx, _ctrl_rx, _cancel, bp) = setup_conn(16); // Simulate prior backpressure. bp.store(2, Ordering::Relaxed); assert!(mgr.send_to(id, "hello".into())); @@ -787,7 +889,7 @@ mod tests { #[test] fn send_to_increments_grace_counter_on_full() { // Buffer size 1 — fill it, then the next send is Full. - let (mgr, id, _rx, cancel, bp) = setup_conn(1); + let (mgr, id, _rx, _ctrl_rx, cancel, bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); // Buffer is now full. assert!(!mgr.send_to(id, "overflow-1".into())); @@ -807,7 +909,7 @@ mod tests { #[test] fn send_to_cancels_after_grace_limit() { - let (mgr, id, _rx, cancel, _bp) = setup_conn(1); + let (mgr, id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); // Exhaust grace: 3 consecutive Full events (matches grace_limit=3 from setup_conn). for _ in 0..3u8 { @@ -849,6 +951,7 @@ mod tests { mgr.register( conn_id, tx, + conn.ctrl_tx.clone(), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -885,12 +988,14 @@ mod tests { let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); mgr.register( conn_id, tx, + ctrl_tx, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, @@ -910,12 +1015,14 @@ mod tests { let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); mgr.register( conn_id, tx, + ctrl_tx, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, @@ -929,4 +1036,100 @@ mod tests { assert_eq!(mgr.pubkey_for_conn(conn_id), Some(pubkey)); assert_eq!(mgr.pubkey_for_conn(Uuid::new_v4()), None); } + + #[tokio::test] + async fn disconnect_pubkey_closes_matching_conns_with_reason() { + let (mgr, id, _rx, mut ctrl_rx, cancel, _bp) = setup_conn(8); + let pubkey = vec![3u8; 32]; + mgr.set_authenticated_pubkey(id, pubkey.clone()); + + // setup_conn registers the connection under the nil community. + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let closed = mgr.disconnect_pubkey( + community, + &pubkey, + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 1, "the one matching connection is closed"); + assert!( + cancel.is_cancelled(), + "connection is cancelled (socket close)" + ); + // The reason frame is queued on the control channel ahead of the close. + let frame = ctrl_rx.try_recv().expect("reason frame delivered"); + match frame { + WsMessage::Text(t) => { + assert!(t.as_str().contains("blocked: banned"), "carries the reason"); + assert!(t.as_str().contains("false"), "is an OK false frame"); + } + other => panic!("expected text frame, got {other:?}"), + } + } + + #[tokio::test] + async fn disconnect_pubkey_ignores_non_matching_conns() { + let (mgr, id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + mgr.set_authenticated_pubkey(id, vec![1u8; 32]); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let closed = mgr.disconnect_pubkey( + community, + &[2u8; 32], + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 0, "no connection matches a different pubkey"); + assert!(!cancel.is_cancelled(), "unrelated connection stays live"); + } + + #[tokio::test] + async fn disconnect_pubkey_is_fenced_to_the_banning_community() { + // Same pubkey, two live sockets in two different communities on one pod. + // A ban in community A must close only A's socket, never B's — the + // tenant fence on live-disconnect fan-out (B1). + let mgr = ConnectionManager::new(); + let pubkey = vec![7u8; 32]; + + let community_a = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xa)); + let community_b = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xb)); + + let register = |community| { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); + cancel + }; + + let cancel_a = register(community_a); + let cancel_b = register(community_b); + + let closed = mgr.disconnect_pubkey( + community_a, + &pubkey, + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 1, "only the community-A socket is closed"); + assert!(cancel_a.is_cancelled(), "community-A session is closed"); + assert!( + !cancel_b.is_cancelled(), + "community-B session stays live — ban does not cross the tenant fence" + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 5cb2bf118..31493c898 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, @@ -385,15 +387,44 @@ pub fn build_edit( Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags)) } +/// Optional metadata for moderator delete tombstones (kind 9005). +#[derive(Debug, Clone, Default)] +pub struct DeleteMessageOptions<'a> { + /// Audit action UUID to link from the public tombstone. + pub action_id: Option, + /// Machine-readable, public-safe reason code. + pub reason_code: Option<&'a str>, + /// Human-readable reason safe for the room-facing tombstone. + pub public_reason: Option<&'a str>, +} + /// Build a Buzz-native delete event (kind 9005). pub fn build_delete_message( channel_id: Uuid, target_event_id: nostr::EventId, ) -> Result { - let tags = vec![ + build_delete_message_with_options(channel_id, target_event_id, DeleteMessageOptions::default()) +} + +/// Build a Buzz-native delete event (kind 9005) with optional moderation metadata. +pub fn build_delete_message_with_options( + channel_id: Uuid, + target_event_id: nostr::EventId, + options: DeleteMessageOptions<'_>, +) -> Result { + let mut tags = vec![ tag(&["h", &channel_id.to_string()])?, tag(&["e", &target_event_id.to_hex()])?, ]; + if let Some(action_id) = options.action_id { + tags.push(tag(&["action_id", &action_id.to_string()])?); + } + if let Some(reason_code) = options.reason_code { + tags.push(tag(&["reason_code", reason_code])?); + } + if let Some(public_reason) = options.public_reason { + tags.push(tag(&["public_reason", public_reason])?); + } Ok(EventBuilder::new(Kind::Custom(9005), "").tags(tags)) } @@ -1511,6 +1542,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::*; @@ -1884,6 +2020,31 @@ mod tests { assert_eq!(ev.content, ""); } + #[test] + fn delete_message_with_moderation_metadata() { + let cid = uuid(); + let eid = event_id(); + let action_id = Uuid::new_v4(); + let ev = sign( + build_delete_message_with_options( + cid, + eid, + DeleteMessageOptions { + action_id: Some(action_id), + reason_code: Some("spam"), + public_reason: Some("Removed for spam."), + }, + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 9005); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "e", &eid.to_hex())); + assert!(has_tag(&ev, "action_id", &action_id.to_string())); + assert!(has_tag(&ev, "reason_code", "spam")); + assert!(has_tag(&ev, "public_reason", "Removed for spam.")); + } + #[test] fn delete_compat_happy_path() { let cid = uuid(); @@ -3134,4 +3295,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(_))); + } } diff --git a/migrations/0006_moderation.sql b/migrations/0006_moderation.sql new file mode 100644 index 000000000..17656973e --- /dev/null +++ b/migrations/0006_moderation.sql @@ -0,0 +1,130 @@ +-- Community moderation (Phase 1): reports, bans/timeouts, audit actions. +-- +-- Design: PLANS/COMMUNITY_MODERATION_PLAN.md §0 (decisions locked by Tyler, +-- 2026-07-07). All three tables are tenant-scoped: community_id NOT NULL and +-- community-id-leading keys, per the tenant-isolation lints in +-- crates/buzz-db/src/migration.rs. Report/ban targets are only ever resolved +-- under the requesting TenantContext — no global lookups (MOD invariants, +-- docs/spec/MultiTenantRelay.tla). + +-- ── NIP-56 reports (kind:1984 ingest) ───────────────────────────────────────── +-- One row per accepted report event. Reports are signals, never triggers: +-- nothing auto-actions on them (NIP-56). Reporter identity is visible to +-- moderators in the queue but never revealed to the reported author. + +CREATE TABLE moderation_reports ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + -- The signed kind:1984 event id (stored for audit/idempotency). + report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), + reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32), + -- What was reported. Exactly one target class per row (CHECK-enforced below). + target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32), + -- Channel inferred from an in-tenant target event row, when resolvable. + channel_id UUID, + -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. + report_type TEXT NOT NULL, + -- Reporter's optional free-text context (mod-queue-only; never public). + note TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')), + resolved_by BYTEA, + resolved_at TIMESTAMPTZ, + -- moderation_actions row that resolved this report, if any. + action_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + -- Exactly one target class per row: target_kind is authoritative and the + -- matching column (only) is populated. Queue/action code never guesses. + CHECK ( + (target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL) + ), + -- Same-community channel provenance (channels are soft-deleted, never + -- hard-deleted, so this FK cannot dangle). + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +-- Queue reads: open reports, newest first, per community. +CREATE INDEX idx_moderation_reports_status + ON moderation_reports (community_id, status, created_at DESC); +-- Group-by-target for triage aggregation. +CREATE INDEX idx_moderation_reports_target_event + ON moderation_reports (community_id, target_event_id) + WHERE target_event_id IS NOT NULL; +CREATE INDEX idx_moderation_reports_target_pubkey + ON moderation_reports (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; +-- Idempotency: one row per report event per community. +CREATE UNIQUE INDEX idx_moderation_reports_event + ON moderation_reports (community_id, report_event_id); + +-- ── Bans + timeouts (one restriction row per member) ────────────────────────── +-- Ban = connection block, enforced at the NIP-42 auth seam +-- ("blocked: you are banned from this community") + join/ingest surfaces. +-- Timeout = write-block only ("restricted: you are timed out until "). +-- A row may be ban-only, timeout-only, or both over its lifetime. + +CREATE TABLE community_bans ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), + banned BOOLEAN NOT NULL DEFAULT false, + -- NULL + banned=true ⇒ permanent. + ban_expires_at TIMESTAMPTZ, + ban_reason TEXT, + -- Write-block until this timestamp; NULL or past ⇒ not timed out. + muted_until TIMESTAMPTZ, + mute_reason TEXT, + -- Moderator who last modified this row. + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) +); + +-- ── Moderation audit ────────────────────────────────────────────────────────── +-- One row per accepted moderation action. Full detail (reporter identities, +-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public +-- tombstone carries only action_id + reason_code + sanitized public_reason. + +CREATE TABLE moderation_actions ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + action TEXT NOT NULL CHECK (action IN ( + 'delete_message', 'kick', 'ban', 'unban', + 'timeout', 'untimeout', 'dismiss_report', 'escalate', + 'resolve:delete', 'resolve:kick', 'resolve:ban', + 'resolve:timeout')), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + channel_id UUID, + -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). + reason_code TEXT, + -- Sanitized, safe for the public tombstone. + public_reason TEXT, + -- Mod-only context; never leaves the audit surface. + private_reason TEXT, + -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, + -- the client never learns which. + matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +CREATE INDEX idx_moderation_actions_created + ON moderation_actions (community_id, created_at DESC); +CREATE INDEX idx_moderation_actions_target_pubkey + ON moderation_actions (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; + +-- Same-community resolution provenance: a report can only be resolved by an +-- action row in its own community. Added after moderation_actions exists. +ALTER TABLE moderation_reports + ADD FOREIGN KEY (community_id, action_id) + REFERENCES moderation_actions (community_id, id); diff --git a/schema/schema.sql b/schema/schema.sql index 36deebfca..e31347d72 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -606,6 +606,128 @@ CREATE TABLE audit_log ( CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash); +-- ── NIP-56 reports (kind:1984 ingest) ───────────────────────────────────────── +-- One row per accepted report event. Reports are signals, never triggers: +-- nothing auto-actions on them (NIP-56). Reporter identity is visible to +-- moderators in the queue but never revealed to the reported author. + +CREATE TABLE moderation_reports ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + -- The signed kind:1984 event id (stored for audit/idempotency). + report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), + reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32), + -- What was reported. Exactly one target class per row (CHECK-enforced below). + target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32), + -- Channel inferred from an in-tenant target event row, when resolvable. + channel_id UUID, + -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. + report_type TEXT NOT NULL, + -- Reporter's optional free-text context (mod-queue-only; never public). + note TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')), + resolved_by BYTEA, + resolved_at TIMESTAMPTZ, + -- moderation_actions row that resolved this report, if any. + action_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + -- Exactly one target class per row: target_kind is authoritative and the + -- matching column (only) is populated. Queue/action code never guesses. + CHECK ( + (target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL) + ), + -- Same-community channel provenance (channels are soft-deleted, never + -- hard-deleted, so this FK cannot dangle). + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +-- Queue reads: open reports, newest first, per community. +CREATE INDEX idx_moderation_reports_status + ON moderation_reports (community_id, status, created_at DESC); +-- Group-by-target for triage aggregation. +CREATE INDEX idx_moderation_reports_target_event + ON moderation_reports (community_id, target_event_id) + WHERE target_event_id IS NOT NULL; +CREATE INDEX idx_moderation_reports_target_pubkey + ON moderation_reports (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; +-- Idempotency: one row per report event per community. +CREATE UNIQUE INDEX idx_moderation_reports_event + ON moderation_reports (community_id, report_event_id); + +-- ── Bans + timeouts (one restriction row per member) ────────────────────────── +-- Ban = connection block, enforced at the NIP-42 auth seam +-- ("blocked: you are banned from this community") + join/ingest surfaces. +-- Timeout = write-block only ("restricted: you are timed out until "). +-- A row may be ban-only, timeout-only, or both over its lifetime. + +CREATE TABLE community_bans ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), + banned BOOLEAN NOT NULL DEFAULT false, + -- NULL + banned=true ⇒ permanent. + ban_expires_at TIMESTAMPTZ, + ban_reason TEXT, + -- Write-block until this timestamp; NULL or past ⇒ not timed out. + muted_until TIMESTAMPTZ, + mute_reason TEXT, + -- Moderator who last modified this row. + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) +); + +-- ── Moderation audit ────────────────────────────────────────────────────────── +-- One row per accepted moderation action. Full detail (reporter identities, +-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public +-- tombstone carries only action_id + reason_code + sanitized public_reason. + +CREATE TABLE moderation_actions ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + action TEXT NOT NULL CHECK (action IN ( + 'delete_message', 'kick', 'ban', 'unban', + 'timeout', 'untimeout', 'dismiss_report', 'escalate', + 'resolve:delete', 'resolve:kick', 'resolve:ban', + 'resolve:timeout')), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + channel_id UUID, + -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). + reason_code TEXT, + -- Sanitized, safe for the public tombstone. + public_reason TEXT, + -- Mod-only context; never leaves the audit surface. + private_reason TEXT, + -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, + -- the client never learns which. + matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +CREATE INDEX idx_moderation_actions_created + ON moderation_actions (community_id, created_at DESC); +CREATE INDEX idx_moderation_actions_target_pubkey + ON moderation_actions (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; + +-- Same-community resolution provenance: a report can only be resolved by an +-- action row in its own community. Added after moderation_actions exists. +ALTER TABLE moderation_reports + ADD FOREIGN KEY (community_id, action_id) + REFERENCES moderation_actions (community_id, id); + -- ── Lint allowlist registry ─────────────────────────────────────────────────── -- The explicit registry of tables that are deliberately operator-global (NOT -- tenant-scoped). The migration-lint harness reads this: any table NOT listed