diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6e5eab1aa5..b56ca79e2b 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -245,6 +245,20 @@ impl BuzzClient { self.handle_response(resp).await } + /// GET an authed relay endpoint (NIP-98), returning the raw JSON body. + /// + /// `path` is a root-relative path incl. any query string, e.g. + /// `/moderation/reports?status=open&limit=20`. Used by the moderation + /// read commands, which read structured queue/audit rows rather than + /// stored events. + pub async fn get_authed(&self, path: &str) -> Result { + let url = format!("{}{path}", self.relay_url); + let auth = sign_nip98(&self.keys, "GET", &url, None)?; + let req = self.http.get(&url).header("Authorization", &auth); + let resp = self.with_auth_tag(req).send().await?; + self.handle_response(resp).await + } + /// Submit a signed Nostr event via POST /events. pub async fn submit_event(&self, event: nostr::Event) -> Result { let url = format!("{}/events", self.relay_url); diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 714fcf5d67..ed510a9815 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 cc62f9f193..9a15957c51 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod feed; pub mod issues; pub mod mem; pub mod messages; +pub mod moderation; pub mod notes; pub mod pack; pub mod patches; diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs new file mode 100644 index 0000000000..c53aecaf85 --- /dev/null +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -0,0 +1,165 @@ +//! `buzz moderation` — community moderation queue, enforcement, and audit. +//! +//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed +//! command events (kinds 9040–9044) submitted via `POST /events`, mirroring +//! the NIP-43 relay-admin 9030-series: the relay validates, authorizes +//! (owner/admin only), and executes them directly — they are never stored. +//! +//! Reads (`reports`/`restricted`/`audit`) hit dedicated mod-only, +//! NIP-98-authed relay endpoints under `/moderation/*`, because reports and +//! audit rows are structured queue rows, not public nostr events — serving +//! them over a REQ filter would mean synthesizing fake events and threading a +//! privileged authz check into the public read path. +//! +//! The community (tenant) is selected by the relay host — moderation commands +//! carry no channel scope. + +use nostr::Timestamp; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::validate_hex64; +use crate::{ModerationCmd, OutputFormat}; + +/// Resolve `--expires-in ` / `--expires-at ` into an absolute +/// unix-seconds expiry. At most one may be set (enforced by clap). +fn resolve_expiry(expires_in: Option, expires_at: Option) -> Option { + match (expires_in, expires_at) { + (Some(secs), _) => Some(Timestamp::now().as_secs() + secs), + (None, Some(ts)) => Some(ts), + (None, None) => None, + } +} + +async fn cmd_ban( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let expiry = resolve_expiry(expires_in, expires_at); + let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) + .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let builder = buzz_sdk::build_moderation_unban(pubkey) + .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_timeout( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let expiry = resolve_expiry(expires_in, expires_at) + .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; + let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) + .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let builder = buzz_sdk::build_moderation_untimeout(pubkey) + .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_resolve( + client: &BuzzClient, + report: &str, + status: &str, + action: &str, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(report)?; + let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason) + .map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_reports( + client: &BuzzClient, + status: Option<&str>, + limit: i64, +) -> Result<(), CliError> { + let mut path = format!("/moderation/reports?limit={limit}"); + if let Some(s) = status { + path.push_str(&format!("&status={s}")); + } + let resp = client.get_authed(&path).await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> { + let resp = client.get_authed("/moderation/restricted").await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> { + let resp = client + .get_authed(&format!("/moderation/audit?limit={limit}")) + .await?; + println!("{resp}"); + Ok(()) +} + +pub async fn dispatch( + cmd: ModerationCmd, + client: &BuzzClient, + _format: &OutputFormat, +) -> Result<(), CliError> { + match cmd { + ModerationCmd::Reports { status, limit } => { + cmd_reports(client, status.as_deref(), limit).await + } + ModerationCmd::Resolve { + report, + status, + action, + reason, + } => cmd_resolve(client, &report, &status, &action, reason.as_deref()).await, + ModerationCmd::Ban { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_ban(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Unban { pubkey } => cmd_unban(client, &pubkey).await, + ModerationCmd::Timeout { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await, + ModerationCmd::Restricted => cmd_restricted(client).await, + ModerationCmd::Audit { limit } => cmd_audit(client, limit).await, + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 191cc73d9b..7ee1958284 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 ae9e7ef13f..0bd1a9fbd7 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 d78c84350c..eb791e4410 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 69cb48df89..b255e79cbe 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 0000000000..be8b712d45 --- /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 0000000000..a562980a79 --- /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 550f32d65e..eae8c5ef9e 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 c32e80bbeb..affea69191 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 cadbbf116c..f6765553eb 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 bc356979f5..a5d6be50c7 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 ad26fe5f2e..9ab57bab86 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 9abb5948f9..4b348a5205 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 4a6f1becb1..aee9bb63a1 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 da47d80a15..74aef955af 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 0000000000..2f2781dd1d --- /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 0000000000..4a74352b4c --- /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 0000000000..0e5b51ef2f --- /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 0000000000..fccf8eb42b --- /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 5c898ea1eb..a02324669f 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 973b7f68bf..083739bf3b 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 03e0065efb..32e178b168 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 fc9e1ec38e..44565ae763 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 a674e6d7c9..ce955524fc 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 5cb2bf118c..31493c8985 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/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index a40e14dedb..d15ee82abc 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -227,7 +227,7 @@ struct RelayInformationDocument { self_: Option, } -async fn fetch_relay_self(state: &AppState) -> Result, String> { +pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { let relay_url = relay_ws_url_with_override(state); let http_url = relay_http_base_url(&relay_url); let response = state @@ -318,6 +318,18 @@ pub async fn list_archived_identities( }) } +/// Read the active relay's NIP-11 `self` pubkey (its own signing key, hex). +/// +/// A public, unauthenticated document read reused by the moderation UI to tell +/// whether a DM peer is the relay identity (a moderation DM). Fails open: an +/// unreachable relay, a document without `self`, or a malformed value all +/// return `None`, and callers must treat that as "not the relay" — the disable +/// is an affordance, not enforcement, so a false negative is the safe failure. +#[tauri::command] +pub async fn get_relay_self(state: State<'_, AppState>) -> Result, String> { + fetch_relay_self(&state).await +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 341c99d7b1..ee7af6b502 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -518,6 +518,7 @@ pub fn run() { archive_identity, unarchive_identity, list_archived_identities, + get_relay_self, resolve_oa_owner, list_relay_agents, list_managed_agents, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 6740359d64..8dc157d067 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -3,6 +3,10 @@ import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; +import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; +import { isModerationDm } from "@/features/moderation/lib/moderationDm"; +import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import { MessageThreadPanel, @@ -269,10 +273,24 @@ export const ChannelPane = React.memo(function ChannelPane({ return true; }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); + const timeoutState = useTimeoutState(); + + // A moderation DM (1:1 with the relay identity) is read-only for the member; + // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → + // ordinary DM, composer enabled. + const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); + const isModerationDmChannel = isModerationDm( + activeChannel ?? null, + currentPubkey, + relaySelfQuery.data, + ); + const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || activeChannel.channelType === "forum" || + timeoutState.active || + isModerationDmChannel || isSending; const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); @@ -676,7 +694,11 @@ export const ChannelPane = React.memo(function ChannelPane({ ref={composerWrapperRef} >
- {isActiveWelcomeChannel ? ( + {timeoutState.active ? ( + + ) : isActiveWelcomeChannel ? ( ) : null} diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx new file mode 100644 index 0000000000..11a2dcd09d --- /dev/null +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; +import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +export function EditRespondToDialog({ + agent, + currentPubkey, + onOpenChange, + open, +}: { + agent: ManagedAgent | null; + currentPubkey?: string; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const updateMutation = useUpdateManagedAgentMutation(); + const [respondTo, setRespondTo] = React.useState("owner-only"); + const [respondToAllowlist, setRespondToAllowlist] = React.useState( + [], + ); + + React.useEffect(() => { + if (agent) { + setRespondTo(agent.respondTo); + setRespondToAllowlist([...agent.respondToAllowlist]); + } + }, [agent]); + + const respondToValid = + respondTo !== "allowlist" || respondToAllowlist.length > 0; + + async function handleSave() { + if (!agent) return; + await updateMutation.mutateAsync({ + pubkey: agent.pubkey, + respondTo, + respondToAllowlist: + respondTo === "allowlist" ? respondToAllowlist : undefined, + }); + onOpenChange(false); + } + + return ( + + + + Edit respond-to + + Choose who {agent?.name ?? "this agent"} responds to. + + + + {updateMutation.error instanceof Error ? ( +

+ {updateMutation.error.message} +

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index b1b55417c7..77f99cca7f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -6,13 +6,11 @@ import { useChannelMembersQuery, useChannelsQuery, } from "@/features/channels/hooks"; -import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, } from "@/features/agents/lib/agentAutocompleteEligibility"; -import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; @@ -32,7 +30,6 @@ import type { Channel, ChannelMember, ManagedAgent, - RespondToMode, UserSearchResult, } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -40,7 +37,6 @@ import { Dialog, DialogClose, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -54,10 +50,13 @@ import { MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; +import { EditRespondToDialog } from "./EditRespondToDialog"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; +import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; const MEMBER_ROW_INSET_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + function formatAddCandidateName(user: UserSearchResult) { return ( user.displayName?.trim() || @@ -437,6 +436,17 @@ export function MembersSidebar({ const canManageMembers = selfMember?.role === "owner" || selfMember?.role === "admin"; + + const { + canModerate, + isModerationPending, + moderationStateByPubkey, + onBan, + onUnban, + onTimeout, + onUntimeout, + } = useMembersSidebarModeration(open); + const isArchived = channel?.archivedAt !== null && channel?.archivedAt !== undefined; const managedAgentByPubkey = React.useMemo( @@ -563,8 +573,13 @@ export function MembersSidebar({
{ void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); }} @@ -586,6 +605,9 @@ export function MembersSidebar({ }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} + onTimeout={onTimeout} + onUnban={onUnban} + onUntimeout={onUntimeout} onViewActivity={ onViewActivity ? (pubkey: string) => { @@ -897,86 +919,3 @@ function AddMemberSearchResultRow({
); } - -function EditRespondToDialog({ - agent, - currentPubkey, - onOpenChange, - open, -}: { - agent: ManagedAgent | null; - currentPubkey?: string; - onOpenChange: (open: boolean) => void; - open: boolean; -}) { - const updateMutation = useUpdateManagedAgentMutation(); - const [respondTo, setRespondTo] = React.useState("owner-only"); - const [respondToAllowlist, setRespondToAllowlist] = React.useState( - [], - ); - - React.useEffect(() => { - if (agent) { - setRespondTo(agent.respondTo); - setRespondToAllowlist([...agent.respondToAllowlist]); - } - }, [agent]); - - const respondToValid = - respondTo !== "allowlist" || respondToAllowlist.length > 0; - - async function handleSave() { - if (!agent) return; - await updateMutation.mutateAsync({ - pubkey: agent.pubkey, - respondTo, - respondToAllowlist: - respondTo === "allowlist" ? respondToAllowlist : undefined, - }); - onOpenChange(false); - } - - return ( - - - - Edit respond-to - - Choose who {agent?.name ?? "this agent"} responds to. - - - - {updateMutation.error instanceof Error ? ( -

- {updateMutation.error.message} -

- ) : null} -
- - -
-
-
- ); -} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 6fea3abe64..8234517432 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -1,11 +1,15 @@ import { Activity, + Ban, Bot, + CircleSlash, + Clock, Ellipsis, Pencil, Play, RotateCcw, Shield, + ShieldCheck, Square, Trash2, } from "lucide-react"; @@ -36,6 +40,7 @@ import { type MembersSidebarMemberCardProps = { canChangeRole: boolean; + canModerate: boolean; canRemoveMember: boolean; isActionPending: boolean; isArchived: boolean; @@ -44,17 +49,35 @@ type MembersSidebarMemberCardProps = { memberAvatarLabel: string; memberIsBot: boolean; memberLabel: string; + moderationState?: MemberModerationState; + onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onOpenProfile?: (pubkey: string) => void; onRemoveMember: (member: ChannelMember) => void; + onTimeout: (member: ChannelMember, expiresAtSecs: number) => void; + onUnban: (member: ChannelMember) => void; + onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; presenceStatus?: PresenceStatus | null; profileAvatarUrl?: string | null; viewerIsOwner: boolean; }; +/** Whether a member is currently banned / timed out (from the restriction read). */ +export type MemberModerationState = { + banned: boolean; + timedOut: boolean; +}; + +/** Timeout durations offered in the member menu, in seconds. */ +const TIMEOUT_PRESETS: { label: string; seconds: number }[] = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "24 hours", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, +]; + const MEMBER_ROW_INSET_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; @@ -96,6 +119,7 @@ function formatManagedAgentStatus(agent: ManagedAgent) { export function MembersSidebarMemberCard({ canChangeRole, + canModerate, canRemoveMember, isActionPending, isArchived, @@ -104,11 +128,16 @@ export function MembersSidebarMemberCard({ memberAvatarLabel, memberIsBot, memberLabel, + moderationState, + onBan, onChangeRole, onEditRespondTo, onManagedAgentAction, onOpenProfile, onRemoveMember, + onTimeout, + onUnban, + onUntimeout, onViewActivity, presenceStatus, profileAvatarUrl, @@ -120,9 +149,13 @@ export function MembersSidebarMemberCard({ memberIsBot && (viewerIsOwner || managedAgent?.backend.type === "local") && Boolean(onViewActivity); + // Community ban/timeout applies to people, never bots, and never the community + // owner (whom no moderator can restrict). + const canModerateMember = + canModerate && !memberIsBot && member.role !== "owner"; const hasActions = memberIsBot ? Boolean(managedAgent) || canRemoveMember || canViewActivity - : canRemoveMember || canChangeRole; + : canRemoveMember || canChangeRole || canModerateMember; const memberIdentity = (
@@ -215,16 +248,22 @@ export function MembersSidebarMemberCard({ {hasActions ? ( ) : null} @@ -236,33 +275,47 @@ const PEOPLE_ROLES = ["admin", "member", "guest"] as const; function MemberActionsMenu({ canChangeRole, + canModerateMember, canRemoveMember, canViewActivity, disabled, managedAgent, member, memberIsBot, + moderationState, + onBan, onChangeRole, onEditRespondTo, onManagedAgentAction, onRemoveMember, + onTimeout, + onUnban, + onUntimeout, onViewActivity, }: { canChangeRole: boolean; + canModerateMember: boolean; canRemoveMember: boolean; canViewActivity: boolean; disabled: boolean; managedAgent?: ManagedAgent; member: ChannelMember; memberIsBot: boolean; + moderationState?: MemberModerationState; + onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; + onTimeout: (member: ChannelMember, expiresAtSecs: number) => void; + onUnban: (member: ChannelMember) => void; + onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; + const isBanned = moderationState?.banned ?? false; + const isTimedOut = moderationState?.timedOut ?? false; return ( @@ -353,6 +406,70 @@ function MemberActionsMenu({ ) : null} + {canModerateMember ? ( + <> + {canRemoveMember || showChangeRole ? ( + + ) : null} + {isTimedOut ? ( + onUntimeout(member)} + > + + Lift timeout + + ) : ( + + + + Time out + + + {TIMEOUT_PRESETS.map((preset) => ( + + onTimeout( + member, + Math.floor(Date.now() / 1000) + preset.seconds, + ) + } + > + {preset.label} + + ))} + + + )} + {isBanned ? ( + onUnban(member)} + > + + Lift ban + + ) : ( + onBan(member)} + > + + Ban from community + + )} + + ) : null} ); diff --git a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts new file mode 100644 index 0000000000..9a546d4836 --- /dev/null +++ b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts @@ -0,0 +1,114 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + useBanMemberMutation, + useModerationRestrictionsQuery, + useTimeoutMemberMutation, + useUnbanMemberMutation, + useUntimeoutMemberMutation, +} from "@/features/moderation/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { isTimedOut } from "@/features/moderation/lib/restrictionState"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +import type { MemberModerationState } from "./MembersSidebarMemberCard"; + +/** + * Owns community ban/timeout wiring for the members sidebar. Gated by relay + * role (owner/admin), independent of the per-channel role — the relay rejects + * the command events otherwise. Restrictions are only fetched while the sidebar + * is open and the caller can moderate. + */ +export function useMembersSidebarModeration(open: boolean) { + const relayMembershipQuery = useMyRelayMembershipQuery(); + const relayRole = relayMembershipQuery.data?.role; + const canModerate = relayRole === "owner" || relayRole === "admin"; + const restrictionsQuery = useModerationRestrictionsQuery(open && canModerate); + const banMutation = useBanMemberMutation(); + const unbanMutation = useUnbanMemberMutation(); + const timeoutMutation = useTimeoutMemberMutation(); + const untimeoutMutation = useUntimeoutMemberMutation(); + const isModerationPending = + banMutation.isPending || + unbanMutation.isPending || + timeoutMutation.isPending || + untimeoutMutation.isPending; + + const moderationStateByPubkey = React.useMemo(() => { + const nowMs = Date.now(); + const map = new Map(); + for (const restriction of restrictionsQuery.data ?? []) { + map.set(normalizePubkey(restriction.pubkey), { + banned: restriction.banned, + timedOut: isTimedOut(restriction.mutedUntil, nowMs), + }); + } + return map; + }, [restrictionsQuery.data]); + + const runModerationAction = React.useCallback( + async (action: () => Promise, success: string) => { + try { + await action(); + toast.success(success); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Moderation action failed", + ); + } + }, + [], + ); + + const onBan = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => banMutation.mutateAsync({ pubkey: member.pubkey }), + "Member banned", + ), + [banMutation, runModerationAction], + ); + + const onUnban = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => unbanMutation.mutateAsync(member.pubkey), + "Ban lifted", + ), + [unbanMutation, runModerationAction], + ); + + const onTimeout = React.useCallback( + (member: ChannelMember, expiresAtSecs: number) => + void runModerationAction( + () => + timeoutMutation.mutateAsync({ + pubkey: member.pubkey, + expiresAt: expiresAtSecs, + }), + "Member timed out", + ), + [timeoutMutation, runModerationAction], + ); + + const onUntimeout = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => untimeoutMutation.mutateAsync(member.pubkey), + "Timeout lifted", + ), + [untimeoutMutation, runModerationAction], + ); + + return { + canModerate, + isModerationPending, + moderationStateByPubkey, + onBan, + onUnban, + onTimeout, + onUntimeout, + }; +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 341cdacf83..31a96439df 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -19,6 +19,10 @@ import { resolveReplyRootId, } from "@/features/messages/lib/threading"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + clearTimeoutState, + recordTimeoutFromRejection, +} from "@/features/moderation/lib/timeoutStore"; import { relayClient } from "@/shared/api/relayClient"; import { customEmojiQueryKey } from "@/features/custom-emoji/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; @@ -637,7 +641,11 @@ export function useSendMessageMutation( queryKey, }; }, - onError: (_error, _variables, context) => { + onError: (error, _variables, context) => { + // A community timeout surfaces here as the relay's `OK false` reason. + // Record it so the composer can show the timeout chip and block further + // sends until it expires; other errors fall through to the caller. + recordTimeoutFromRejection(error?.message); if (!context) { return; } @@ -649,6 +657,9 @@ export function useSendMessageMutation( ); }, onSuccess: (message, _variables, context) => { + // An accepted send proves the write-block is lifted; clear any recorded + // timeout so the chip and disable state fall away immediately. + clearTimeoutState(); if (!context) { return; } diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index d483ab2219..5e487d1d59 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -5,6 +5,7 @@ import { Copy, CornerUpLeft, EllipsisVertical, + Flag, Link2, MailCheck, MailOpen, @@ -18,6 +19,8 @@ import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog"; +import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems"; import type { TimelineMessage, TimelineReaction, @@ -89,6 +92,7 @@ function MoreActionsMenu({ isUnread?: boolean; }) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false); // Set true the moment the user picks "Edit message". The // `onCloseAutoFocus` handler on `DropdownMenuContent` reads it to // suppress Radix's default focus-restoration (which would yank focus @@ -102,6 +106,14 @@ function MoreActionsMenu({ const hasCopyActions = !message.pending && message.kind !== KIND_HUDDLE_STARTED; + // A report needs a real, delivered event to target and a known author to + // name in the NIP-56 `p` tag. Pending sends and system huddle rows have + // neither, so the entry is hidden for them. + const canReport = + !message.pending && + message.kind !== KIND_HUDDLE_STARTED && + Boolean(message.pubkey); + return ( <> @@ -228,20 +240,38 @@ function MoreActionsMenu({ ) : null} + {canReport || onDelete ? : null} + + {canReport ? ( + { + setIsReportDialogOpen(true); + }} + > + + Report message + + ) : null} + {onDelete ? ( - <> - - { - setIsDeleteDialogOpen(true); - }} - > - - Delete message - - + { + setIsDeleteDialogOpen(true); + }} + > + + Delete message + + ) : null} + + {canReport ? ( + ) : null} @@ -277,6 +307,15 @@ function MoreActionsMenu({ ) : null} + + {canReport ? ( + + ) : null} ); } diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index df4dafe451..ffdff4436e 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -33,6 +33,12 @@ type SystemMessagePayload = { target?: string; topic?: string; purpose?: string; + // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and + // moderator-authored — present when a moderator removed the message, absent for + // a plain member self-delete. Reporter identity/evidence never appears here. + public_reason?: string; + reason_code?: string; + action_id?: string; }; type SystemMessageDescription = { @@ -326,6 +332,21 @@ function describeSystemEvent( title: actorName, action: "unarchived this channel", }; + case "message_deleted": { + // Room-facing tombstone. When a moderator removed the message, the relay + // stamps a sanitized public_reason; a plain self-delete carries none. The + // content and the reporter are never disclosed here. + if (payload.public_reason) { + return { + title: "Removed by community moderators", + action: payload.public_reason, + }; + } + return { + title: actorName, + action: "removed a message", + }; + } default: return null; } diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts new file mode 100644 index 0000000000..d5c8ed24ef --- /dev/null +++ b/desktop/src/features/moderation/hooks.ts @@ -0,0 +1,170 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; +import { + banMember, + type CommunityRestriction, + listAuditActions, + listReports, + listRestrictions, + type ModerationAction, + type ModerationReport, + type ReportType, + type ResolutionAction, + type ResolutionStatus, + resolveReport, + submitReport, + timeoutMember, + unbanMember, + untimeoutMember, +} from "@/shared/api/moderation"; + +export const moderationReportsQueryKey = ["moderationReports"] as const; +export const moderationAuditQueryKey = ["moderationAudit"] as const; +export const moderationRestrictionsQueryKey = [ + "moderationRestrictions", +] as const; +export const relaySelfQueryKey = ["relaySelf"] as const; + +/** + * The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises + * none / is unreachable. Used to recognize a moderation DM. Workspace-scoped + * and effectively static for a session, so it is cached indefinitely; a `null` + * result is a valid answer (fail open), not an error to retry into. + */ +export function useRelaySelfQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: relaySelfQueryKey, + queryFn: getRelaySelf, + staleTime: Number.POSITIVE_INFINITY, + }); +} + +// --- Reads (mod-authz gated; consumed by the U2 queue/audit surfaces) --- + +export function useModerationReportsQuery( + options?: { status?: string; limit?: number }, + enabled = true, +) { + return useQuery({ + enabled, + queryKey: [ + ...moderationReportsQueryKey, + options?.status ?? null, + options?.limit ?? null, + ], + queryFn: () => listReports(options), + staleTime: 15_000, + }); +} + +export function useModerationAuditQuery(limit?: number, enabled = true) { + return useQuery({ + enabled, + queryKey: [...moderationAuditQueryKey, limit ?? null], + queryFn: () => listAuditActions(limit), + staleTime: 15_000, + }); +} + +export function useModerationRestrictionsQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: moderationRestrictionsQueryKey, + queryFn: listRestrictions, + staleTime: 15_000, + }); +} + +// --- Writes --- +// +// Moderation writes are relay-validated command events whose effects surface in +// the queue/audit/restricted reads after processing, so mutations invalidate the +// affected read queries on success rather than fabricating optimistic rows. + +function useInvalidateModerationReads() { + const queryClient = useQueryClient(); + return () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: moderationReportsQueryKey }), + queryClient.invalidateQueries({ queryKey: moderationAuditQueryKey }), + queryClient.invalidateQueries({ + queryKey: moderationRestrictionsQueryKey, + }), + ]); +} + +/** Submit a NIP-56 report. Does not touch the mod-gated read caches. */ +export function useSubmitReportMutation() { + return useMutation({ + mutationFn: (input: { + authorPubkey: string; + eventId: string; + reportType: ReportType; + note?: string; + }) => submitReport(input), + }); +} + +export function useBanMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + pubkey: string; + expiresAt?: number; + reason?: string; + }) => banMember(input), + onSuccess: invalidate, + }); +} + +export function useUnbanMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (pubkey: string) => unbanMember(pubkey), + onSuccess: invalidate, + }); +} + +export function useTimeoutMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + pubkey: string; + expiresAt: number; + reason?: string; + }) => timeoutMember(input), + onSuccess: invalidate, + }); +} + +export function useUntimeoutMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (pubkey: string) => untimeoutMember(pubkey), + onSuccess: invalidate, + }); +} + +export function useResolveReportMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + reportEventId: string; + status: ResolutionStatus; + action: ResolutionAction; + reason?: string; + }) => resolveReport(input), + onSuccess: invalidate, + }); +} + +export type { + CommunityRestriction, + ModerationAction, + ModerationReport, + ReportType, + ResolutionAction, + ResolutionStatus, +}; diff --git a/desktop/src/features/moderation/lib/moderationDm.test.mjs b/desktop/src/features/moderation/lib/moderationDm.test.mjs new file mode 100644 index 0000000000..c1ee97ae3f --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isModerationDm } from "./moderationDm.ts"; + +const RELAY = "a".repeat(64); +const ME = "b".repeat(64); +const OTHER = "c".repeat(64); + +const dm = (participantPubkeys) => ({ channelType: "dm", participantPubkeys }); + +test("moderation DM: 1:1 with the relay self is detected", () => { + assert.equal(isModerationDm(dm([ME, RELAY]), ME, RELAY), true); +}); + +test("moderation DM: case-insensitive on both self and participants", () => { + assert.equal(isModerationDm(dm([ME, RELAY.toUpperCase()]), ME, RELAY), true); +}); + +test("ordinary DM with another member is not a moderation DM", () => { + assert.equal(isModerationDm(dm([ME, OTHER]), ME, RELAY), false); +}); + +test("group DM including the relay is not a moderation DM", () => { + assert.equal(isModerationDm(dm([ME, RELAY, OTHER]), ME, RELAY), false); +}); + +test("non-DM channels are never moderation DMs", () => { + assert.equal( + isModerationDm( + { channelType: "stream", participantPubkeys: [ME, RELAY] }, + ME, + RELAY, + ), + false, + ); +}); + +test("fails open when relay self is null/undefined", () => { + assert.equal(isModerationDm(dm([ME, RELAY]), ME, null), false); + assert.equal(isModerationDm(dm([ME, RELAY]), ME, undefined), false); +}); + +test("null channel is not a moderation DM", () => { + assert.equal(isModerationDm(null, ME, RELAY), false); +}); diff --git a/desktop/src/features/moderation/lib/moderationDm.ts b/desktop/src/features/moderation/lib/moderationDm.ts new file mode 100644 index 0000000000..3165cd52c8 --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.ts @@ -0,0 +1,31 @@ +import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * A moderation DM is the 1:1 direct message between a member and the relay's + * own identity — the channel moderators use to explain an action. The member + * must not be able to reply into it, so the composer is disabled on this + * channel alone (never on ordinary DMs). + * + * Identification is client-side and best-effort: the relay identity is the + * NIP-11 `self` pubkey (see {@link getRelaySelf}), and a moderation DM is a DM + * whose only other participant is that pubkey. It is an affordance, not + * enforcement — the relay decides what a write does — so this fails open: a + * missing `relaySelf` (relay unreachable, no `self` advertised) yields `false`, + * leaving the composer enabled. + */ +export function isModerationDm( + channel: Pick | null, + currentPubkey: string | undefined, + relaySelf: string | null | undefined, +): boolean { + if (channel?.channelType !== "dm" || !relaySelf) { + return false; + } + const self = normalizePubkey(relaySelf); + const me = currentPubkey ? normalizePubkey(currentPubkey) : null; + const others = channel.participantPubkeys + .map(normalizePubkey) + .filter((pubkey) => pubkey !== me); + return others.length === 1 && others[0] === self; +} diff --git a/desktop/src/features/moderation/lib/relaySelf.ts b/desktop/src/features/moderation/lib/relaySelf.ts new file mode 100644 index 0000000000..fbe5d6503b --- /dev/null +++ b/desktop/src/features/moderation/lib/relaySelf.ts @@ -0,0 +1,12 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * Read the active relay's NIP-11 `self` pubkey (its own signing key, hex), or + * `null` when the relay advertises none / is unreachable / serves a malformed + * document. Used by the moderation UI to recognize a DM with the relay identity + * (a moderation DM). Fails open by contract: a `null` result must be treated as + * "not the relay", never as an error. + */ +export function getRelaySelf(): Promise { + return invokeTauri("get_relay_self"); +} diff --git a/desktop/src/features/moderation/lib/restrictionState.test.mjs b/desktop/src/features/moderation/lib/restrictionState.test.mjs new file mode 100644 index 0000000000..1c22dd66a7 --- /dev/null +++ b/desktop/src/features/moderation/lib/restrictionState.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isTimedOut, parseRestrictionTimestampMs } from "./restrictionState.ts"; + +test("parses an RFC3339 string to epoch ms", () => { + assert.equal( + parseRestrictionTimestampMs("2026-07-07T22:00:00Z"), + Date.parse("2026-07-07T22:00:00Z"), + ); +}); + +test("treats a legacy number as unix seconds", () => { + assert.equal(parseRestrictionTimestampMs(1751920000), 1751920000 * 1000); +}); + +test("returns null for a null value", () => { + assert.equal(parseRestrictionTimestampMs(null), null); +}); + +test("returns null for an unparseable string (fails closed)", () => { + assert.equal(parseRestrictionTimestampMs("not-a-date"), null); +}); + +test("isTimedOut is true for a future muted-until", () => { + const now = 1_000_000_000_000; + assert.equal(isTimedOut(new Date(now + 60_000).toISOString(), now), true); +}); + +test("isTimedOut is false for a past muted-until", () => { + const now = 1_000_000_000_000; + assert.equal(isTimedOut(new Date(now - 60_000).toISOString(), now), false); +}); + +test("isTimedOut is false for an absent muted-until (fail closed to not-timed-out)", () => { + assert.equal(isTimedOut(null), false); +}); + +test("isTimedOut is false for an unparseable muted-until", () => { + assert.equal(isTimedOut("garbage"), false); +}); diff --git a/desktop/src/features/moderation/lib/restrictionState.ts b/desktop/src/features/moderation/lib/restrictionState.ts new file mode 100644 index 0000000000..9f6fa82cca --- /dev/null +++ b/desktop/src/features/moderation/lib/restrictionState.ts @@ -0,0 +1,41 @@ +/** + * Derive a member's live moderation state from a `CommunityRestriction` row. + * + * Shared by the two admin surfaces that gate on it (the members sidebar and the + * per-message action cluster) so the ban/timeout reads can't drift. + */ + +/** Whether a member is currently banned and/or timed out. */ +export type MemberRestrictionState = { + banned: boolean; + timedOut: boolean; +}; + +/** + * Coerce a restriction timestamp to epoch milliseconds. The wire emits + * `DateTime` as an RFC3339 string, but the shared type still tolerates the + * legacy `number` (unix seconds) shape, so handle both: strings parse as ISO, + * numbers are treated as unix seconds. Returns `null` for absent or unparseable + * values — fails closed, so a bad value never renders a phantom timeout. + */ +export function parseRestrictionTimestampMs( + value: string | number | null, +): number | null { + if (value == null) return null; + if (typeof value === "number") return value * 1000; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * True when a `mutedUntil` value is still in the future relative to `nowMs`. + * An absent or unparseable value is *not* an active timeout (fail closed to + * "not timed out" so the UI doesn't strand a member who has no live mute). + */ +export function isTimedOut( + mutedUntil: string | number | null, + nowMs: number = Date.now(), +): boolean { + const ms = parseRestrictionTimestampMs(mutedUntil); + return ms != null && ms > nowMs; +} diff --git a/desktop/src/features/moderation/lib/timeout.test.mjs b/desktop/src/features/moderation/lib/timeout.test.mjs new file mode 100644 index 0000000000..de8727ce89 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatTimeoutRemaining, + isTimeoutActive, + parseTimeoutRejection, + timeoutExpiresAt, + TIMEOUT_PRESETS, +} from "./timeout.ts"; + +test("parses a well-formed timeout rejection to epoch ms", () => { + const result = parseTimeoutRejection( + "restricted: you are timed out until 1751920000", + ); + assert.deepEqual(result, { expiresAtMs: 1751920000 * 1000 }); +}); + +test("tolerates surrounding whitespace", () => { + const result = parseTimeoutRejection( + " restricted: you are timed out until 1751920000 ", + ); + assert.deepEqual(result, { expiresAtMs: 1751920000 * 1000 }); +}); + +test("returns null for a non-timeout rejection", () => { + assert.equal( + parseTimeoutRejection("blocked: you are banned from this community"), + null, + ); + assert.equal(parseTimeoutRejection("restricted: not a channel member"), null); + assert.equal(parseTimeoutRejection(""), null); + assert.equal(parseTimeoutRejection(null), null); + assert.equal(parseTimeoutRejection(undefined), null); +}); + +test("timeout with unparseable timestamp still signals timed-out", () => { + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until soon"), + { expiresAtMs: null }, + ); + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until "), + { expiresAtMs: null }, + ); + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until -5"), + { expiresAtMs: null }, + ); +}); + +test("isTimeoutActive: future expiry active, past expiry inactive", () => { + const now = 1_000_000_000_000; + assert.equal(isTimeoutActive(now + 5000, now), true); + assert.equal(isTimeoutActive(now - 5000, now), false); +}); + +test("isTimeoutActive: unknown expiry fails closed (active)", () => { + assert.equal(isTimeoutActive(null, 1_000_000_000_000), true); +}); + +test("formatTimeoutRemaining: hours, minutes, seconds tiers", () => { + const now = 1_000_000_000_000; + assert.equal( + formatTimeoutRemaining(now + (2 * 3600 + 5 * 60) * 1000, now), + "2h 5m", + ); + assert.equal( + formatTimeoutRemaining(now + (3 * 60 + 20) * 1000, now), + "3m 20s", + ); + assert.equal(formatTimeoutRemaining(now + 12 * 1000, now), "12s"); +}); + +test("formatTimeoutRemaining: null when unknown or elapsed", () => { + const now = 1_000_000_000_000; + assert.equal(formatTimeoutRemaining(null, now), null); + assert.equal(formatTimeoutRemaining(now - 1000, now), null); + assert.equal(formatTimeoutRemaining(now, now), null); +}); + +test("timeoutExpiresAt: absolute expiry is now (seconds) + preset seconds", () => { + const nowMs = 1_000_000_000_000; + assert.equal(timeoutExpiresAt(3600, nowMs), 1_000_000_000 + 3600); + // Floors sub-second now before adding, so the result is a whole second. + assert.equal(timeoutExpiresAt(60, nowMs + 999), 1_000_000_000 + 60); +}); + +test("TIMEOUT_PRESETS: the shared 1h/24h/7d set", () => { + assert.deepEqual( + TIMEOUT_PRESETS.map((preset) => preset.seconds), + [60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60], + ); +}); diff --git a/desktop/src/features/moderation/lib/timeout.ts b/desktop/src/features/moderation/lib/timeout.ts new file mode 100644 index 0000000000..a7b729da31 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.ts @@ -0,0 +1,120 @@ +/** + * Reactive detection of a community timeout from a send rejection. + * + * The relay refuses writes from a timed-out member with an `OK false` message + * of the exact form (ingest.rs, load-bearing parse contract): + * + * restricted: you are timed out until + * + * There is no proactive self-restriction read in v1, so the composer learns it + * is timed out only by attempting a send and inspecting the rejection. This is + * the Option-A (reactive) ruling. + */ + +const TIMEOUT_PREFIX = "restricted: you are timed out until"; + +/** + * The community-timeout durations offered wherever a moderator picks one — the + * per-message author cluster (U2) and the report-queue timeout resolution. + * Kept here as the single source of truth so the two surfaces can never drift. + */ +export const TIMEOUT_PRESETS: ReadonlyArray<{ + label: string; + seconds: number; +}> = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "24 hours", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, +]; + +/** + * Convert a preset duration into the absolute expiry (epoch **seconds**) the + * timeout command (`useTimeoutMemberMutation`) expects — `now + seconds`. The + * relay stamps its own authoritative expiry; this is the client's request. + */ +export function timeoutExpiresAt( + seconds: number, + nowMs: number = Date.now(), +): number { + return Math.floor(nowMs / 1000) + seconds; +} + +export type TimeoutRejection = { + /** + * Timeout expiry in epoch milliseconds, or `null` when the relay's message + * carried an unparseable timestamp. A `null` expiry still means "timed out" — + * the caller shows the chip without a countdown rather than pretending the + * member can send. + */ + expiresAtMs: number | null; +}; + +/** + * Parse a relay send-rejection message. Returns a {@link TimeoutRejection} when + * the message is a timeout refusal, or `null` for any other rejection (which + * the caller surfaces through its normal error path, untouched). + * + * Defensive by contract: the prefix match is what identifies a timeout; the + * timestamp is best-effort. A malformed or out-of-range trailing value yields + * `expiresAtMs: null`, never a throw and never a false negative on the prefix. + */ +export function parseTimeoutRejection( + message: string | null | undefined, +): TimeoutRejection | null { + if (!message) { + return null; + } + const trimmed = message.trim(); + if (!trimmed.startsWith(TIMEOUT_PREFIX)) { + return null; + } + const rest = trimmed.slice(TIMEOUT_PREFIX.length).trim(); + const seconds = Number.parseInt(rest, 10); + if (!Number.isSafeInteger(seconds) || seconds <= 0) { + return { expiresAtMs: null }; + } + return { expiresAtMs: seconds * 1000 }; +} + +/** + * True when a known timeout expiry is still in the future relative to `nowMs`. + * A `null` expiry (unknown) is treated as still-active — fail closed, since the + * member was demonstrably timed out at their last send attempt. + */ +export function isTimeoutActive( + expiresAtMs: number | null, + nowMs: number = Date.now(), +): boolean { + if (expiresAtMs === null) { + return true; + } + return expiresAtMs > nowMs; +} + +/** + * Format the time left until `expiresAtMs` as a short human string for the + * composer chip: `"2h 5m"`, `"3m 20s"`, `"12s"`. Returns `null` when there is + * no countdown to show — either the expiry is unknown or already elapsed. + */ +export function formatTimeoutRemaining( + expiresAtMs: number | null, + nowMs: number = Date.now(), +): string | null { + if (expiresAtMs === null) { + return null; + } + const totalSeconds = Math.ceil((expiresAtMs - nowMs) / 1000); + if (totalSeconds <= 0) { + return null; + } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} diff --git a/desktop/src/features/moderation/lib/timeoutStore.ts b/desktop/src/features/moderation/lib/timeoutStore.ts new file mode 100644 index 0000000000..2b7affe8f1 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeoutStore.ts @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + isTimeoutActive, + parseTimeoutRejection, +} from "@/features/moderation/lib/timeout"; + +/** + * Process-wide store for the member's current community timeout, learned + * reactively from send rejections (there is no proactive read in v1). A single + * value suffices: the desktop app is bound to one community per relay + * connection, and a timeout blocks every channel's writes at the auth seam. + * + * Kept as a tiny external store rather than React Query cache because the value + * is written from a mutation's error path and read by the composer with a live + * countdown — an imperative setter plus `useSyncExternalStore` is the smallest + * primitive that fits both. + */ + +let expiresAtMs: number | null = null; +// `true` once a timeout is recorded, until it expires or a send is accepted. +// Distinguishes "not timed out" (null + inactive) from "timed out, unknown +// expiry" (active with a null `expiresAtMs`). +let active = false; +const listeners = new Set<() => void>(); + +// Cached snapshot so `useSyncExternalStore` gets a referentially stable value +// between changes (returning a fresh object each read would loop forever). +let snapshot: TimeoutState = { active: false, expiresAtMs: null }; + +function emit() { + snapshot = { active, expiresAtMs }; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Inspect a relay send-rejection message. If it is a timeout refusal, record + * the timeout and return `true` so the caller can suppress its generic error + * surface. Any other rejection returns `false` and is left untouched. + */ +export function recordTimeoutFromRejection( + message: string | null | undefined, +): boolean { + const rejection = parseTimeoutRejection(message); + if (!rejection) { + return false; + } + expiresAtMs = rejection.expiresAtMs; + active = true; + emit(); + return true; +} + +/** Clear the timeout — called when a send is accepted (the block is lifted). */ +export function clearTimeoutState(): void { + if (!active && expiresAtMs === null) { + return; + } + expiresAtMs = null; + active = false; + emit(); +} + +export type TimeoutState = { + /** True while the member is timed out (write-blocked). */ + active: boolean; + /** Expiry in epoch ms, or null when the relay gave no parseable timestamp. */ + expiresAtMs: number | null; +}; + +const INACTIVE: TimeoutState = { active: false, expiresAtMs: null }; + +function currentState(state: TimeoutState, nowMs: number): TimeoutState { + if (!state.active) { + return INACTIVE; + } + if (!isTimeoutActive(state.expiresAtMs, nowMs)) { + // A known expiry has passed; collapse to inactive so the next read is + // clean even before a send re-probes the block. + return INACTIVE; + } + return state; +} + +/** + * Subscribe to the timeout state. Re-renders on record/clear and, while a + * known-expiry timeout is active, ticks once a second so a countdown UI stays + * live and auto-clears exactly at expiry. + */ +export function useTimeoutState(): TimeoutState { + const [nowMs, setNowMs] = React.useState(() => Date.now()); + const state = React.useSyncExternalStore( + subscribe, + () => snapshot, + () => INACTIVE, + ); + + React.useEffect(() => { + if (!state.active || state.expiresAtMs === null) { + return; + } + setNowMs(Date.now()); + const id = window.setInterval(() => { + setNowMs(Date.now()); + }, 1000); + return () => { + window.clearInterval(id); + }; + }, [state.active, state.expiresAtMs]); + + return currentState(state, nowMs); +} diff --git a/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx b/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx new file mode 100644 index 0000000000..a609184093 --- /dev/null +++ b/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx @@ -0,0 +1,34 @@ +import { Clock } from "lucide-react"; + +import { formatTimeoutRemaining } from "@/features/moderation/lib/timeout"; + +/** + * A banner docked to the top edge of the composer while the member is timed + * out by community moderators. Shows a live countdown when the expiry is known; + * otherwise states the block without a timer (the relay gave no timestamp). + * + * Purely presentational — the timeout state and its per-second tick are owned + * by {@link useTimeoutState}; this only formats what it is handed. + */ +export function ComposerTimeoutBanner({ + expiresAtMs, +}: { + /** Timeout expiry in epoch ms, or null when the relay gave no timestamp. */ + expiresAtMs: number | null; +}) { + const remaining = formatTimeoutRemaining(expiresAtMs); + + return ( +
+ + + {remaining + ? `You're timed out by community moderators — ${remaining} left.` + : "You're timed out by community moderators."} + +
+ ); +} diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx new file mode 100644 index 0000000000..6a60b8f8e7 --- /dev/null +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -0,0 +1,207 @@ +import { Ban, CircleSlash, Clock, ShieldCheck, UserMinus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useRemoveChannelMemberMutation } from "@/features/channels/hooks"; +import { + useBanMemberMutation, + useModerationRestrictionsQuery, + useTimeoutMemberMutation, + useUnbanMemberMutation, + useUntimeoutMemberMutation, +} from "@/features/moderation/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import type { TimelineMessage } from "@/features/messages/types"; +import { isTimedOut } from "@/features/moderation/lib/restrictionState"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/shared/ui/dropdown-menu"; + +const TIMEOUT_PRESETS: { label: string; seconds: number }[] = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "24 hours", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, +]; + +/** + * Mod-only per-message actions against the message *author*: time out, ban, and + * kick from the current channel. Self-contained (wires its own hooks, no props + * threaded from the message row), mirroring ReportMessageDialog. + * + * Renders nothing unless the viewer is a relay owner/admin, the message has a + * real signer, and that signer is not the viewer. Actions target + * `signerPubkey` — the raw signer, never the display-author (which `p`/`actor` + * tags can override) — per the security note on TimelineMessage. + */ +export function MessageModerationMenuItems({ + channelId, + message, +}: { + channelId?: string | null; + message: TimelineMessage; +}) { + const relayMembershipQuery = useMyRelayMembershipQuery(); + const relayRole = relayMembershipQuery.data?.role; + const canModerate = relayRole === "owner" || relayRole === "admin"; + + const identityQuery = useIdentityQuery(); + // Fail closed: moderate only the raw signer, never the display `pubkey` + // (which `p`/`actor` tags can override — see the security note above). A + // message without a signer is not moderatable here; render nothing. + const targetPubkey = message.signerPubkey ?? null; + const isSelf = + targetPubkey != null && + identityQuery.data?.pubkey != null && + normalizePubkey(targetPubkey) === + normalizePubkey(identityQuery.data.pubkey); + + const enabled = canModerate && targetPubkey != null && !isSelf; + + const restrictionsQuery = useModerationRestrictionsQuery(enabled); + const banMutation = useBanMemberMutation(); + const unbanMutation = useUnbanMemberMutation(); + const timeoutMutation = useTimeoutMemberMutation(); + const untimeoutMutation = useUntimeoutMemberMutation(); + const removeMutation = useRemoveChannelMemberMutation(channelId ?? null); + const isPending = + banMutation.isPending || + unbanMutation.isPending || + timeoutMutation.isPending || + untimeoutMutation.isPending || + removeMutation.isPending; + + const restriction = React.useMemo(() => { + if (targetPubkey == null) return null; + const key = normalizePubkey(targetPubkey); + return ( + restrictionsQuery.data?.find((r) => normalizePubkey(r.pubkey) === key) ?? + null + ); + }, [restrictionsQuery.data, targetPubkey]); + + const isBanned = restriction?.banned ?? false; + const timedOut = isTimedOut(restriction?.mutedUntil ?? null); + + const run = React.useCallback( + async (action: () => Promise, success: string) => { + try { + await action(); + toast.success(success); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Moderation action failed", + ); + } + }, + [], + ); + + if (!enabled || targetPubkey == null) return null; + + return ( + <> + + {timedOut ? ( + + void run( + () => untimeoutMutation.mutateAsync(targetPubkey), + "Timeout lifted", + ) + } + > + + Lift timeout + + ) : ( + + + + Time out author + + + {TIMEOUT_PRESETS.map((preset) => ( + + void run( + () => + timeoutMutation.mutateAsync({ + pubkey: targetPubkey, + expiresAt: + Math.floor(Date.now() / 1000) + preset.seconds, + }), + "Author timed out", + ) + } + > + {preset.label} + + ))} + + + )} + + {channelId ? ( + + void run( + () => removeMutation.mutateAsync(targetPubkey), + "Author removed from channel", + ) + } + > + + Kick from channel + + ) : null} + + {isBanned ? ( + + void run( + () => unbanMutation.mutateAsync(targetPubkey), + "Ban lifted", + ) + } + > + + Lift ban + + ) : ( + + void run( + () => banMutation.mutateAsync({ pubkey: targetPubkey }), + "Author banned", + ) + } + > + + Ban author from community + + )} + + ); +} diff --git a/desktop/src/features/moderation/ui/ReportMessageDialog.tsx b/desktop/src/features/moderation/ui/ReportMessageDialog.tsx new file mode 100644 index 0000000000..1cbc71c90d --- /dev/null +++ b/desktop/src/features/moderation/ui/ReportMessageDialog.tsx @@ -0,0 +1,139 @@ +import { Flag } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useSubmitReportMutation } from "@/features/moderation/hooks"; +import type { ReportType } from "@/features/moderation/hooks"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Textarea } from "@/shared/ui/textarea"; + +/** NIP-56 report categories, in the order shown to the reporter. `other` is + * last so it reads as the fallback rather than a first-class choice. */ +const REPORT_CATEGORIES: { value: ReportType; label: string }[] = [ + { value: "spam", label: "Spam" }, + { value: "profanity", label: "Profanity or hate speech" }, + { value: "nudity", label: "Nudity or sexual content" }, + { value: "impersonation", label: "Impersonation" }, + { value: "malware", label: "Malware or scam" }, + { value: "illegal", label: "Illegal content" }, + { value: "other", label: "Other" }, +]; + +export function ReportMessageDialog({ + open, + onOpenChange, + authorPubkey, + eventId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Display author of the reported message (the `p` tag target). */ + authorPubkey: string; + /** Reported message event id (the `e` tag target). */ + eventId: string; +}) { + const submitReport = useSubmitReportMutation(); + const [category, setCategory] = React.useState(null); + const [note, setNote] = React.useState(""); + + // Reset the form each time the dialog opens so a prior report's selection + // never leaks into the next one. + React.useEffect(() => { + if (open) { + setCategory(null); + setNote(""); + } + }, [open]); + + const submit = () => { + if (!category || submitReport.isPending) return; + submitReport.mutate( + { + authorPubkey, + eventId, + reportType: category, + note: note.trim() || undefined, + }, + { + onSuccess: () => { + toast.success("Report submitted to community moderators"); + onOpenChange(false); + }, + onError: () => toast.error("Failed to submit report"), + }, + ); + }; + + return ( + + + + + + Report message + + + Reports go to this community's moderators for review. The author is + not notified of who reported them. + + + +
+ {REPORT_CATEGORIES.map((item) => ( + + ))} +
+ +
+ +