From d2b8e3dec358094cb7cd794ca22f6ce65ad1c7c5 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 11:37:48 -0400 Subject: [PATCH 01/24] =?UTF-8?q?feat(moderation):=20Phase=201=20contract?= =?UTF-8?q?=20scaffold=20=E2=80=94=20schema,=20kinds,=20module=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community moderation build (design: docs/moderation/PLAN.md, decisions locked by Tyler 2026-07-07). This commit pins the cross-lane contract: - migrations/0006_moderation.sql: moderation_reports (NIP-56 queue), community_bans (ban + muted_until timeout), moderation_actions (audit). All tenant-scoped, community_id-leading keys per the isolation lints. - buzz-core kinds: KIND_REPORT (1984), moderation commands 9040-9044. - buzz-db::moderation: persistence contract (signatures + row types). - buzz-relay handler seams: moderation_authz (capability helper), report (1984 ingest), moderation_commands (9040-9044 dispatch), moderation_notices (relay-signed DM notices). Bodies are todo!() stubs owned by implementation lanes; signatures are the interface. Reports are signals, never triggers (NIP-56); targets resolve under the requesting TenantContext only. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-core/src/kind.rs | 25 ++ crates/buzz-db/src/lib.rs | 2 + crates/buzz-db/src/migration.rs | 19 +- crates/buzz-db/src/moderation.rs | 291 ++++++++++++++++++ crates/buzz-relay/src/handlers/mod.rs | 8 + .../src/handlers/moderation_authz.rs | 92 ++++++ .../src/handlers/moderation_commands.rs | 40 +++ .../src/handlers/moderation_notices.rs | 70 +++++ crates/buzz-relay/src/handlers/report.rs | 49 +++ docs/moderation/PLAN.md | 247 +++++++++++++++ migrations/0006_moderation.sql | 111 +++++++ 11 files changed, 953 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-db/src/moderation.rs create mode 100644 crates/buzz-relay/src/handlers/moderation_authz.rs create mode 100644 crates/buzz-relay/src/handlers/moderation_commands.rs create mode 100644 crates/buzz-relay/src/handlers/moderation_notices.rs create mode 100644 crates/buzz-relay/src/handlers/report.rs create mode 100644 docs/moderation/PLAN.md create mode 100644 migrations/0006_moderation.sql diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ae9e7ef13f..65dcf9f971 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,23 @@ 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 queue row (`report` tag = row UUID, +/// `status` tag = resolved|dismissed|escalated). +pub const KIND_MODERATION_RESOLVE_REPORT: u32 = 9044; + // NIP-43 relay membership admin commands /// NIP-43: Add a pubkey to the relay member list. pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d78c84350c..21a170a70f 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. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 69cb48df89..95bedf2858 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,23 @@ 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")); + assert!(!migrations[0].sql.as_str().contains("moderation_reports")); } #[test] diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs new file mode 100644 index 0000000000..f86c2605b0 --- /dev/null +++ b/crates/buzz-db/src/moderation.rs @@ -0,0 +1,291 @@ +//! 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; +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, +} + +/// 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` (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, + /// 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 { + todo!("L1 (Max): INSERT ... ON CONFLICT (community_id, report_event_id) DO NOTHING + return 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> { + todo!("L1 (Max)") +} + +/// Fetch one report by row id. +pub async fn get_report( + _pool: &PgPool, + _community: CommunityId, + _report_id: Uuid, +) -> Result> { + todo!("L1 (Max)") +} + +/// 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 { + todo!("L1 (Max)") +} + +/// 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<()> { + todo!("L1 (Max): INSERT ... ON CONFLICT (community_id, pubkey) DO UPDATE") +} + +/// 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 { + todo!("L1 (Max)") +} + +/// 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<()> { + todo!("L1 (Max)") +} + +/// 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 { + todo!("L1 (Max)") +} + +/// 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 { + todo!("L1 (Max): single SELECT evaluating ban expiry + mute window in SQL") +} + +/// Fetch the full ban/timeout row (moderation queue / audit views). +pub async fn get_ban( + _pool: &PgPool, + _community: CommunityId, + _pubkey: &[u8], +) -> Result> { + todo!("L1 (Max)") +} + +/// List currently-restricted members (active ban or timeout) for the queue. +pub async fn list_restricted(_pool: &PgPool, _community: CommunityId) -> Result> { + todo!("L1 (Max)") +} + +/// Insert a moderation audit row, returning its id. +pub async fn insert_action( + _pool: &PgPool, + _community: CommunityId, + _action: NewAction<'_>, +) -> Result { + todo!("L1 (Max)") +} + +/// List audit rows, newest first (`buzz moderation audit`). +pub async fn list_actions( + _pool: &PgPool, + _community: CommunityId, + _limit: i64, +) -> Result> { + todo!("L1 (Max)") +} 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..9047c0acc8 --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -0,0 +1,92 @@ +//! 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 { + todo!("L2 (Mari): relay_members role + channel role lookup, owner>admin guard rails") +} 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..ba42aee76c --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -0,0 +1,40 @@ +//! 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. +//! +//! Lane ownership: L6 (Quinn) — plus `buzz-cli` `moderation` command group. +//! The `ingest.rs` routing entries (scope map + direct-processing dispatch) +//! for 9040–9044 belong to L3 (Perci): coordinate, don't edit ingest.rs. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use nostr::Event; + +use crate::state::AppState; + +/// Validate and execute a moderation command (kinds 9040–9044). +/// +/// Returns a client-safe error string for `OK false` on rejection. +pub async fn handle_moderation_command( + _tenant: &TenantContext, + _state: &Arc, + _event: &Event, +) -> Result<(), String> { + todo!("L6 (Quinn): dispatch 9040–9044 through moderation_authz + buzz_db::moderation") +} 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..709162c6a6 --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -0,0 +1,70 @@ +//! 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 buzz_core::tenant::TenantContext; +use uuid::Uuid; + +use crate::state::AppState; + +/// 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` | `escalated`. + 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). +/// +/// Idempotent per (action/report id, recipient): re-delivery is a no-op. +pub async fn send_moderation_notice( + _tenant: &TenantContext, + _state: &Arc, + _recipient_pubkey: &[u8], + _notice: ModerationNotice, +) -> anyhow::Result<()> { + todo!("L5 (Sami): DM channel reuse, 39000 discovery, relay-signed kind:9, kind:0 profile") +} diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs new file mode 100644 index 0000000000..22d8ae2eea --- /dev/null +++ b/crates/buzz-relay/src/handlers/report.rs @@ -0,0 +1,49 @@ +//! 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 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> { + todo!("L3 (Perci): validate report-type tag, tenant-fenced target resolution, insert_report") +} diff --git a/docs/moderation/PLAN.md b/docs/moderation/PLAN.md new file mode 100644 index 0000000000..59d43dc7be --- /dev/null +++ b/docs/moderation/PLAN.md @@ -0,0 +1,247 @@ +# Community-Admin Moderation Plan + +**Goal (Tyler, 2026-07-01):** let community admins moderate their own communities. +Co-authored by Eva + Wren. Grounded against `buzz-1321-review @ 86d6388` and +`RESEARCH/NOSTR_CONTENT_REPORTING_MODERATION.md`. Re-verified against fresh +`REPOS/buzz-moderation-ref` @ main `1f5ba5b` (2026-07-07). + +## 0. DECISIONS LOCKED (Tyler, 2026-07-07, #buzz-moderation thread e2a91af6) + +All open calls below are now decided. The UX was refined by Eva + Wren to a +co-signed 9/10+ (Wren's line-cited corner-check: event `295db04e`; final +presentation: event `4273365f`). + +1. **Roles: owner + admin only. No Moderator tier for now.** (Tyler, event `559f838f`.) +2. **Reporter identities visible to mods** in the queue (never to the reported author). +3. **Relay-key resolution DM** (Tyler, event `34de7dac`): on report resolution the + relay key authors a regular nostr message stored in the DB. Container (Wren-verified): + relay/moderation key creates/reuses a two-party DM channel `{mod key, user}` via the + participant-hash-idempotent DM model (`buzz-db/src/dm.rs`), emits 39000 discovery + (`hidden`, `t=dm`, `p` tags), inserts relay-signed kind-9 with `h=`. + One DM thread per user per community; relay publishes kind-0 "{Community} Moderation" + identity. `moderation_reports` persists `reporter_pubkey`. Same primitive carries + actioned-author notices and timeout/ban issuance. Non-replyable v1; replies (v2) + route to mod queue as appeals. +4. **Ban/timeout gate at the auth seam** (Tyler, event `34de7dac`): evaluated after + NIP-42 verify, before pubkey allowlist / `enforce_relay_membership` + (`handlers/auth.rs` — gate goes immediately after ~L91). Banned ⇒ + `OK false "blocked: you are banned from this community"` + immediate WS close, zero + further processing (`blocked:` is the NIP-01 standardized prefix; there is no + `banned:` prefix; AUTH must be answered with `OK` per NIP-42). Live ban kill: + cluster-wide disconnect-by-pubkey over Redis pub-sub; `CLOSED "blocked: ..."` per + active sub, then socket close (needs a close-by-pubkey API on `ConnectionManager`). + NIP-OA: gate checks the authenticating pubkey always + owner pubkey when present — + owner ban cascades to agents; agent ban is agent-only; audit records matched + principal, client never learns which. Timeout is a write-block, not a connection + block: `OK false "restricted: you are timed out until "`, desktop disables + composer with countdown chip. No silent write-drops. + +## 1. Where this sits — two moderation layers, not one + +Discord's published model (safety/our-approach-to-content-moderation) is explicitly +**two-layer**, and Buzz should mirror it: + +| Layer | Discord | Buzz | Owner | This plan | +|-------|---------|------|-------|-----------| +| **Platform safety** | Trust & Safety team: CSAM image-hashing → NCMEC, ML network detection, human investigations | Relay operator: sha-match in S3, NIP-86 ban/takedown, NIP-62 vanish | Block / relay operator | **Adjacent** — Fizz's `MODERATION_SAFETY_SKETCH.md` owns it. We cite, don't duplicate. | +| **Community moderation** | Server owners + volunteer mods: AutoMod keyword/spam filters, Warning System | Community admins/mods: NIP-29 admin actions, report queue, in-community bans | Community admins | **THIS PLAN.** | + +The severe-safety class (CSAM) is never delegated to community admins — it's a +platform-level hard-removal + legal-report path. Community moderation is the +subjective, per-community rule enforcement layer on top. + +## 2. What PR #1321 already gives us (the foundation) + +#1321 is **not** a moderation PR — it's the multi-tenant substrate that makes +per-community moderation *safe*: + +- Every scoped row carries a **server-resolved `community_id`** (never caller-supplied). +- `TenantContext` is minted only on the host-resolution path and threaded through + every scoped DB read + Redis publish — a proven cross-community isolation fence. + +**Implication:** community-admin moderation authority is naturally tenant-scoped, but +only if target resolution also stays inside the fence. An admin of community A can +never reach community B's content when the query lists/deletes/bans with +`for_community(A)`. The dangerous edge is a new moderation signal whose *target* is a +bare event id, address, pubkey, or blob hash. Therefore every new moderation table gets +`community_id`, every new action takes `&TenantContext`, and every report/label target +is resolved under `tenant.community()` before it enters a queue or action row. No new +isolation primitive needed; reuse #1321's — but do not add any global target lookup in +the report/label path. + +## 3. What already exists (build-on, don't rebuild) + +From `crates/buzz-relay/src/handlers/side_effects.rs` + `buzz-core`: + +- **NIP-29 admin kinds**, authorized in `validate_admin_event`: + - `9000` put-user (add / assign role), `9001` remove-user (kick), + `9002` edit-metadata, `9005` delete-event, `9008` delete-group, + `9021`/`9022` join/leave request. +- **Role model** (`buzz-core/src/channel.rs`): `Owner > Admin > Member > Guest`, plus + `Bot`. `is_elevated()` = Owner|Admin gates elevated-role grants. Agent-owner + delegation lets the owning human act. +- **39000-series** group-state mirrors (metadata/admins/members/roles). +- **NIP-51 mute list** (kind:10000) constant exists — user-level, client-advisory. + +## 4. The gaps (what this plan adds) + +### Gap A — No distinct Moderator role +Today the only elevated tier is `Admin` (can manage members + settings). Discord's +model leans on a **volunteer-moderator** tier that can act on content/users but +*cannot* reconfigure the community or manage other mods. + +**Recommendation after corner-check:** ship v1 **Admin-only for community-wide +moderation**, with an explicit capability helper, and defer a distinct `Moderator` +role until product needs delegated volunteer moderation. + +Why not add `Moderator` in v1: + +- Community-wide authority lives in the tenant-scoped `relay_members` table, not in + channel `MemberRole`. Adding `Moderator` to `MemberRole` would migrate channel-local + DB enum/API/UI/projection surfaces without actually creating tenant-wide authority. +- Adding `moderator` to `relay_members.role` is possible, but still touches admin + commands, membership announcements, UI, downgrade paths, and tests. That is extra + surface before we have the basic report/delete/ban loop working. +- NIP-29 interop is fine either way because roles are relay-defined/arbitrary, but + clients that only reconstruct channel `39001` admins will not understand a + community-wide moderator unless the relay explains/enforces it. A relay-signed + tombstone is clearer than silently adding every community admin/mod to every group. +- A capability helper now makes v2 cheap: later `moderator` is a role-to-capability + mapping, not an authorization rewrite. + +V1 capability grid: + +| Capability | Community owner | Community admin | Channel owner/admin | Member | +|------------|:---:|:---:|:---:|:---:| +| Delete any message in community (9005) | ✅ | ✅ | channel only | own only | +| Remove/kick user (9001) | ✅ | ✅ | channel only | self | +| Ban user from community | ✅ | ✅ | ❌ | ❌ | +| Timeout/mute user in-community | ✅ | ✅ | ❌ | ❌ | +| Resolve reports (queue actions) | ✅ | ✅ | optional channel-view | ❌ | +| Assign/revoke community Admin | ✅ | ❌ | ❌ | ❌ | +| Edit community-level settings | ✅ | ✅ | ❌ | ❌ | +| Delete community / hard-delete channel (9008) | ✅ | ❌ | channel owner only where already allowed | ❌ | + +Open call for Tyler: **is a distinct Moderator tier required in v1?** My reviewed lean +is **no**: ship Admin-only, but structure authorization around capabilities so adding +`moderator` later is a narrow extension. If Tyler wants Discord-like volunteer mods on +day one, add `moderator` first to `relay_members.role` (tenant-level), advertise it in +`39003`, and only then decide whether channel `MemberRole` also needs a `Moderator`. + +### Gap B — No NIP-56 reports (the report button) +**The single biggest missing primitive.** kind:1984 is referenced only as a CLI +filter example; there is no ingest, queue, or UI. Without it, mods have nothing to +act *on* — they can only delete what they personally see. + +Pipeline (all tenant-scoped via #1321's fence; target resolution is the sharp edge): + +``` +client "Report" → kind:1984 event (p + e/x tags, report-type, free-text) + │ ingest: validate_report_event (new, in side_effects.rs) + ▼ + moderation_reports table (community_id, target_event/pubkey/blob, + reporter, type, reason, created_at, status=Open) + │ aggregate by target; weight trusted reporters (mods > members) + ▼ + per-community moderation queue (buzz-cli / desktop admin surface) + │ mod resolves: dismiss | delete (9005) | remove (9001) | + │ ban | mute | escalate-to-platform + ▼ + moderation_actions table (audit) + relay-signed tombstone (see Gap D) +``` + +Target-resolution rules to keep the tenant fence closed: + +- `e` target: look up the event only with `tenant.community()`; infer `channel_id` + from that row. If missing in this tenant, reject or store as an unresolved report — + never search other tenants by event id. +- `x` blob target: resolve through tenant-scoped media references, e.g. + `(community_id, sha256)` or `(community_id, event_id, sha256)`. A bare SHA-256 can + be shared across tenants and must not grant cross-tenant visibility/action. +- `p`-only target: treat as a community-local report about that pubkey in the current + tenant. It cannot imply a platform/global ban. +- NIP-32 labels (`1985`) in v2 follow the same rule: labels are advisory inputs until + their target is resolved under the current `TenantContext`. + +Key policy (from NIP-56 + the RESEARCH doc): **relays should not auto-moderate on +random-user reports** — they're gameable. Reports are a triage inbox, not an +auto-takedown trigger. Exception: trusted-admin reports of CSAM/illegal class can +fast-path to platform quarantine (Fizz's layer), not to community-admin discretion. + +### Gap C — No persistent ban / in-community mute +`9001` remove-user is a kick — nothing stops re-join on an open channel. Add: +- `community_bans` table (`community_id`, pubkey, actor, reason, expires_at NULL=perm). +- Enforce at join (`9021`/`9000` self-add) and at event ingest for the community. +- **Mute/timeout** = time-boxed write-block (can read, cannot post) — a softer tool + than ban, matches Discord's timeout. Same table, `muted_until` column. + +### Gap D — No "Warning System" (why-was-this-removed) +Discord tells users *why* content was actioned. Buzz already has the right primitive: +`handle_delete_event_side_effect` soft-deletes the target, then emits a relay-signed +kind `40099` system message with `type: "message_deleted"`, `actor`, and +`target_event_id`. Extend that rather than inventing a new 48000-range event for v1. + +Recommended shape: + +- **Public/in-context tombstone:** relay-signed kind `40099`, rendered as "Removed by a + community moderator" without exposing removed content. Add safe fields: + `action_id`, `target_event_id`, `target_author`, `reason_code`, optional sanitized + `public_reason`, and maybe `actor` if moderator identity is intentionally visible. +- **Private author notice:** separate p-gated/DM notification to the actioned user with + the reason, community rule, and appeal/restore path. This is the closer Discord + Warning System analog. +- **Internal action/audit row:** full moderator, report ids, reporter identities, + evidence, and unsafe details stay admin-only. +- **NIP-32 label:** useful in v2 as an advisory/scanner signal, not the authoritative + enforcement record. Labels can be consumed by clients; tombstones explain relay + actions. + +## 5. Standards to adopt vs. skip (from RESEARCH doc, cited to nostr-protocol/nips) + +| NIP | Adopt? | Role in this plan | +|-----|--------|-------------------| +| **NIP-29** groups (9000–9022) | **Yes — already have it.** Extend authorization for community Admin + ban. | Relay-enforced community roles + admin actions. | +| **NIP-56** reports (1984) | **Yes — new.** Gap B. | User report button; interop-standard so other clients work. | +| **NIP-32** labels (1985) | **v2.** | Distributed/automated moderation output; feeds queue as a signal. Client-advisory blur/hide. | +| **NIP-51** mute (10000) | **Yes (exists).** | *User-level* self-mute — client-advisory, orthogonal to community moderation. Keep distinct from Gap C's community mute. | +| **NIP-86** relay-admin API | **Platform layer** (Fizz). | Operator ban/takedown at event/pubkey/IP grain. Not community-admin scope. | +| **NIP-62** vanish (kind:62) | **Platform layer.** | GDPR right-to-erasure; MUST fully delete + block rebroadcast. Legal, not community. | +| **NIP-72** moderated communities | **Skip.** | Deprecated upstream in favor of NIP-29 (README marks it unrecommended). | +| **NIP-09** deletion (kind:5) | Keep (exists). | Author self-delete only; advisory. Not a moderation tool for others' content. | + +## 6. Phased rollout + +**Phase 1 — community-admin MVP (this plan's core):** +1. Add a community moderation capability helper and extend `validate_admin_event` for + community `owner|admin` authority (Admin-only v1 unless Tyler explicitly wants + `moderator`). +2. NIP-56 kind:1984 ingest → tenant-scoped `moderation_reports`, with target + resolution under `TenantContext`. +3. `moderation_actions` audit table + extended relay-signed kind `40099` tombstone for removals. +4. `community_bans` (+ mute/timeout column); enforce at join + ingest. +5. `buzz-cli moderation` queue commands (list/resolve/ban/mute) for admins. + +**Phase 2 — UX + trust weighting:** +6. Desktop/mobile Report button + moderation queue surface. +7. Trusted-reporter weighting; report aggregation by target. +8. Warning-System notifications to actioned users. + +**Phase 3 — distributed signals:** +9. NIP-32 labels (1985) as a queue input + client-advisory hide. +10. Cross-community shared blocklists (opt-in), platform-layer coordination. + +## 7. Open calls for Tyler — ALL DECIDED 2026-07-07, see §0 +- **Moderator role in v1, or Admin-only first?** (§4 Gap A — reviewed lean: Admin-only v1 with capability seams.) +- **Ban grain:** per-community only, or should a community ban be able to request a + platform-level ban for severe cases? (Escalation path to Fizz's layer.) +- **Report visibility:** are reports mod-only, or does a reporter see resolution? +- **Warning System shape:** reviewed lean is relay-signed kind `40099` tombstone + private author notice; NIP-32 label is v2/advisory (§4 Gap D). + +## Sources +- `buzz-1321-review @ 86d6388`: `crates/buzz-core/src/kind.rs`, + `crates/buzz-core/src/channel.rs`, `crates/buzz-relay/src/handlers/side_effects.rs`. +- PR #1321 description (multi-tenant `community_id` / `TenantContext`). +- `RESEARCH/NOSTR_CONTENT_REPORTING_MODERATION.md` (cited to nostr-protocol/nips). +- Discord: safety/our-approach-to-content-moderation (2024-03-15). +- Fizz's `docs/MODERATION_SAFETY_SKETCH.md` (platform media-safety layer, adjacent). +- Wren corner-check: `PLANS/COMMUNITY_MODERATION_CORNER_CHECK_WREN.md`. diff --git a/migrations/0006_moderation.sql b/migrations/0006_moderation.sql new file mode 100644 index 0000000000..b34ffc1e8d --- /dev/null +++ b/migrations/0006_moderation.sql @@ -0,0 +1,111 @@ +-- Community moderation (Phase 1): reports, bans/timeouts, audit actions. +-- +-- Design: PLANS/COMMUNITY_MODERATION_PLAN.md §0 (decisions locked by Tyler, +-- 2026-07-07). All three tables are tenant-scoped: community_id NOT NULL and +-- community-id-leading keys, per the tenant-isolation lints in +-- crates/buzz-db/src/migration.rs. Report/ban targets are only ever resolved +-- under the requesting TenantContext — no global lookups (MOD invariants, +-- docs/spec/MultiTenantRelay.tla). + +-- ── NIP-56 reports (kind:1984 ingest) ───────────────────────────────────────── +-- One row per accepted report event. Reports are signals, never triggers: +-- nothing auto-actions on them (NIP-56). Reporter identity is visible to +-- moderators in the queue but never revealed to the reported author. + +CREATE TABLE moderation_reports ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + -- The signed kind:1984 event id (stored for audit/idempotency). + report_event_id BYTEA NOT NULL, + reporter_pubkey BYTEA NOT NULL, + -- What was reported. Exactly one target class per row. + target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), + target_event_id BYTEA, + target_pubkey BYTEA, + target_blob_sha256 BYTEA, + -- Channel inferred from an in-tenant target event row, when resolvable. + channel_id UUID, + -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. + report_type TEXT NOT NULL, + -- Reporter's optional free-text context (mod-queue-only; never public). + note TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')), + resolved_by BYTEA, + resolved_at TIMESTAMPTZ, + -- moderation_actions row that resolved this report, if any. + action_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id) +); + +-- Queue reads: open reports, newest first, per community. +CREATE INDEX idx_moderation_reports_status + ON moderation_reports (community_id, status, created_at DESC); +-- Group-by-target for triage aggregation. +CREATE INDEX idx_moderation_reports_target_event + ON moderation_reports (community_id, target_event_id) + WHERE target_event_id IS NOT NULL; +CREATE INDEX idx_moderation_reports_target_pubkey + ON moderation_reports (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; +-- Idempotency: one row per report event per community. +CREATE UNIQUE INDEX idx_moderation_reports_event + ON moderation_reports (community_id, report_event_id); + +-- ── Bans + timeouts (one restriction row per member) ────────────────────────── +-- Ban = connection block, enforced at the NIP-42 auth seam +-- ("blocked: you are banned from this community") + join/ingest surfaces. +-- Timeout = write-block only ("restricted: you are timed out until "). +-- A row may be ban-only, timeout-only, or both over its lifetime. + +CREATE TABLE community_bans ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + banned BOOLEAN NOT NULL DEFAULT false, + -- NULL + banned=true ⇒ permanent. + ban_expires_at TIMESTAMPTZ, + ban_reason TEXT, + -- Write-block until this timestamp; NULL or past ⇒ not timed out. + muted_until TIMESTAMPTZ, + mute_reason TEXT, + -- Moderator who last modified this row. + actor_pubkey BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) +); + +-- ── Moderation audit ────────────────────────────────────────────────────────── +-- One row per accepted moderation action. Full detail (reporter identities, +-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public +-- tombstone carries only action_id + reason_code + sanitized public_reason. + +CREATE TABLE moderation_actions ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + actor_pubkey BYTEA NOT NULL, + action TEXT NOT NULL CHECK (action IN ( + 'delete_message', 'kick', 'ban', 'unban', + 'timeout', 'untimeout', 'dismiss_report', 'escalate')), + target_pubkey BYTEA, + target_event_id BYTEA, + channel_id UUID, + -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). + reason_code TEXT, + -- Sanitized, safe for the public tombstone. + public_reason TEXT, + -- Mod-only context; never leaves the audit surface. + private_reason TEXT, + -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, + -- the client never learns which. + matched_principal TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id) +); + +CREATE INDEX idx_moderation_actions_created + ON moderation_actions (community_id, created_at DESC); +CREATE INDEX idx_moderation_actions_target_pubkey + ON moderation_actions (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; From 03c70eada29f0936fc6fe3aad2db08b2e2b52588 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 11:53:56 -0400 Subject: [PATCH 02/24] test(db): derive expected migration versions from MIGRATOR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-Postgres migration test hardcoded [1, 2, 3] and had rotted (migrations 4 and 5 already exist on main; it only passes when skipped). Derive the expected list from the embedded MIGRATOR so it stays honest as additive migrations land — including 0006_moderation. Verified against a fresh local Postgres: all 6 versions apply, and the three moderation tables materialize with community-id-leading keys. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-db/src/migration.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 95bedf2858..02fbc61e92 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -732,7 +732,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 [ From 48461d8c1fc0c32bb891a54f59a520ff2ab715bf Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 11:56:59 -0400 Subject: [PATCH 03/24] =?UTF-8?q?fix(moderation):=20contract=20patch=20?= =?UTF-8?q?=E2=80=94=20DB-enforced=20invariants=20+=20kind=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wren's contract review of 67373d9a (two blockers + pins): - 0006_moderation.sql: CHECK-enforce exactly-one-target-class per report row (target_kind authoritative, matching column only); 32-byte length checks on every pubkey/event-id/sha256 column; matched_principal restricted to self|owner; same-community FKs for channel provenance (reports+actions -> channels) and resolution provenance (reports.action_id -> moderation_actions). - buzz-core: KIND_REPORT + 9040-9044 registered in ALL_KINDS (duplicate detection now covers them); is_moderation_command_kind() as the one canonical route check; compile-time asserts (u16 fit, not ephemeral). - moderation_commands.rs docs: pinned 9040-9044 routing (community-global direct commands, is_global_only_kind, fresh timestamp, never stored, no channel-scoped tokens) and the exact tag vocabulary per kind so the CLI and relay seams cannot diverge. Verified on live Postgres: migration applies; a two-target insert is rejected by moderation_reports_check. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-core/src/kind.rs | 30 +++++++++++++ .../src/handlers/moderation_commands.rs | 26 ++++++++++- migrations/0006_moderation.sql | 45 +++++++++++++------ 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 65dcf9f971..2e25c7a1c3 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -218,6 +218,21 @@ pub const KIND_MODERATION_UNTIMEOUT: u32 = 9043; /// `status` tag = resolved|dismissed|escalated). 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; @@ -503,6 +518,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, @@ -512,6 +528,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, @@ -725,6 +746,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-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index ba42aee76c..9621e8f2c6 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -17,9 +17,31 @@ //! 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: `["report", ]` + `["status", resolved|dismissed|escalated]` +//! + `["resolution", dismiss|delete|kick|ban|timeout|escalate]`; optional +//! `["reason", ]` (private) and `["public_reason", ]` (tombstone-safe). +//! `delete`/`kick`/`ban`/`timeout` resolutions fan out through the existing +//! 9005/9001 paths and the 9040/9042 handlers — no second implementation. +//! //! Lane ownership: L6 (Quinn) — plus `buzz-cli` `moderation` command group. -//! The `ingest.rs` routing entries (scope map + direct-processing dispatch) -//! for 9040–9044 belong to L3 (Perci): coordinate, don't edit ingest.rs. +//! 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; diff --git a/migrations/0006_moderation.sql b/migrations/0006_moderation.sql index b34ffc1e8d..5de8a7ac9a 100644 --- a/migrations/0006_moderation.sql +++ b/migrations/0006_moderation.sql @@ -16,13 +16,13 @@ CREATE TABLE moderation_reports ( community_id UUID NOT NULL REFERENCES communities(id), id UUID NOT NULL DEFAULT gen_random_uuid(), -- The signed kind:1984 event id (stored for audit/idempotency). - report_event_id BYTEA NOT NULL, - reporter_pubkey BYTEA NOT NULL, - -- What was reported. Exactly one target class per row. + report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), + reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32), + -- What was reported. Exactly one target class per row (CHECK-enforced below). target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), - target_event_id BYTEA, - target_pubkey BYTEA, - target_blob_sha256 BYTEA, + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32), -- Channel inferred from an in-tenant target event row, when resolvable. channel_id UUID, -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. @@ -36,7 +36,17 @@ CREATE TABLE moderation_reports ( -- moderation_actions row that resolved this report, if any. action_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (community_id, id) + PRIMARY KEY (community_id, id), + -- Exactly one target class per row: target_kind is authoritative and the + -- matching column (only) is populated. Queue/action code never guesses. + CHECK ( + (target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL) + ), + -- Same-community channel provenance (channels are soft-deleted, never + -- hard-deleted, so this FK cannot dangle). + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -- Queue reads: open reports, newest first, per community. @@ -61,7 +71,7 @@ CREATE UNIQUE INDEX idx_moderation_reports_event CREATE TABLE community_bans ( community_id UUID NOT NULL REFERENCES communities(id), - pubkey BYTEA NOT NULL, + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), banned BOOLEAN NOT NULL DEFAULT false, -- NULL + banned=true ⇒ permanent. ban_expires_at TIMESTAMPTZ, @@ -70,7 +80,7 @@ CREATE TABLE community_bans ( muted_until TIMESTAMPTZ, mute_reason TEXT, -- Moderator who last modified this row. - actor_pubkey BYTEA NOT NULL, + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, pubkey) @@ -84,12 +94,12 @@ CREATE TABLE community_bans ( CREATE TABLE moderation_actions ( community_id UUID NOT NULL REFERENCES communities(id), id UUID NOT NULL DEFAULT gen_random_uuid(), - actor_pubkey BYTEA NOT NULL, + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), action TEXT NOT NULL CHECK (action IN ( 'delete_message', 'kick', 'ban', 'unban', 'timeout', 'untimeout', 'dismiss_report', 'escalate')), - target_pubkey BYTEA, - target_event_id BYTEA, + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), channel_id UUID, -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). reason_code TEXT, @@ -99,9 +109,10 @@ CREATE TABLE moderation_actions ( private_reason TEXT, -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, -- the client never learns which. - matched_principal TEXT, + matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (community_id, id) + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); CREATE INDEX idx_moderation_actions_created @@ -109,3 +120,9 @@ CREATE INDEX idx_moderation_actions_created CREATE INDEX idx_moderation_actions_target_pubkey ON moderation_actions (community_id, target_pubkey) WHERE target_pubkey IS NOT NULL; + +-- Same-community resolution provenance: a report can only be resolved by an +-- action row in its own community. Added after moderation_actions exists. +ALTER TABLE moderation_reports + ADD FOREIGN KEY (community_id, action_id) + REFERENCES moderation_actions (community_id, id); From da925b976436e748d752ffab84c7493cfd00308a Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 12:04:50 -0400 Subject: [PATCH 04/24] ingest: pin 9040-9044 as global-only per contract A stray h tag must never channel-scope a moderation command; the 9040-series follows the NIP-43 9030-series model (executed, never stored, community-global). Closes Wren's contract-review mismatch: the note claimed this was landed at d02c9be4 but only the kind constants were. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/handlers/ingest.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9abb5948f9..b78838c3bc 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -22,12 +22,13 @@ 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_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_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, @@ -366,6 +367,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 From 378165d18c63156d03de7e46daa8d4270b37eb03 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 12:08:52 -0400 Subject: [PATCH 05/24] moderation: align in-tree 9044 contract docs with pinned tag vocabulary The moderation_commands.rs module doc still carried the pre-review shape (report = row uuid, status escalated, resolution/public_reason tags). Pinned vocabulary (thread event 86f46207): report = 1984 event id hex, status resolved|dismissed, required action tag, optional reason -> moderation_actions.public_reason. kind.rs doc for 9044 updated to match. No code changes. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-core/src/kind.rs | 6 ++++-- .../src/handlers/moderation_commands.rs | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 2e25c7a1c3..0bd1a9fbd7 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -214,8 +214,10 @@ pub const KIND_MODERATION_UNBAN: u32 = 9041; 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 queue row (`report` tag = row UUID, -/// `status` tag = resolved|dismissed|escalated). +/// 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). diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 9621e8f2c6..ed026970a3 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -32,11 +32,19 @@ //! - 9042 timeout: `["p", ]` + required `["expiration", ]`; //! optional `["reason", ]`. //! - 9043 untimeout: `["p", ]`. -//! - 9044 resolve: `["report", ]` + `["status", resolved|dismissed|escalated]` -//! + `["resolution", dismiss|delete|kick|ban|timeout|escalate]`; optional -//! `["reason", ]` (private) and `["public_reason", ]` (tombstone-safe). -//! `delete`/`kick`/`ban`/`timeout` resolutions fan out through the existing -//! 9005/9001 paths and the 9040/9042 handlers — no second implementation. +//! - 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. //! //! Lane ownership: L6 (Quinn) — plus `buzz-cli` `moderation` command group. //! The `ingest.rs` routing entries (scope map + `is_global_only_kind` + From b1b26a95050f5b7ead7bd9cd72c4becb1e240a98 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:11:52 -0400 Subject: [PATCH 06/24] feat(db): implement community moderation persistence (#1592) Implements the L1 moderation persistence lane: moderation_reports/community_bans/moderation_actions DB layer, Db wrapper seams, restriction_state enforcement read, report lookup by event id, and ignored Postgres-backed invariant tests (tenant fence, expired-ban+timeout, re-ingest idempotency, double-resolve). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-db/src/lib.rs | 145 +++++++ crates/buzz-db/src/moderation.rs | 712 ++++++++++++++++++++++++++++--- 2 files changed, 794 insertions(+), 63 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 21a170a70f..eb791e4410 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2175,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/moderation.rs b/crates/buzz-db/src/moderation.rs index f86c2605b0..c8ca0f4c15 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/moderation.rs @@ -15,7 +15,7 @@ //! through the integration thread. use chrono::{DateTime, Utc}; -use sqlx::PgPool; +use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; @@ -144,6 +144,8 @@ pub struct ActionRecord { 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, } @@ -151,88 +153,257 @@ pub struct ActionRecord { /// 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<'_>, + pool: &PgPool, + community: CommunityId, + report: NewReport<'_>, ) -> Result { - todo!("L1 (Max): INSERT ... ON CONFLICT (community_id, report_event_id) DO NOTHING + return id") + 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, + pool: &PgPool, + community: CommunityId, + status: Option<&str>, + limit: i64, ) -> Result> { - todo!("L1 (Max)") + 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, + pool: &PgPool, + community: CommunityId, + report_id: Uuid, ) -> Result> { - todo!("L1 (Max)") + 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, + pool: &PgPool, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, ) -> Result { - todo!("L1 (Max)") + 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>, + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, ) -> Result<()> { - todo!("L1 (Max): INSERT ... ON CONFLICT (community_id, pubkey) DO UPDATE") + 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], + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], ) -> Result { - todo!("L1 (Max)") + 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>, + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, ) -> Result<()> { - todo!("L1 (Max)") + 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], + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], ) -> Result { - todo!("L1 (Max)") + 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. @@ -251,41 +422,456 @@ pub struct RestrictionState { /// 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], + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], ) -> Result { - todo!("L1 (Max): single SELECT evaluating ban expiry + mute window in SQL") + 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], + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], ) -> Result> { - todo!("L1 (Max)") + 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> { - todo!("L1 (Max)") +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<'_>, + pool: &PgPool, + community: CommunityId, + action: NewAction<'_>, ) -> Result { - todo!("L1 (Max)") + 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, + pool: &PgPool, + community: CommunityId, + limit: i64, ) -> Result> { - todo!("L1 (Max)") + 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" + ); + } } From f4d4c5b4d2162d2f547f33d7826387788383a88c Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:13:30 -0400 Subject: [PATCH 07/24] moderation(L5): relay-signed moderation notice DMs (#1590) Signed-off-by: Tyler Longwell Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Co-authored-by: Tyler Longwell --- .../src/handlers/moderation_notices.rs | 333 +++++++++++++++++- 1 file changed, 325 insertions(+), 8 deletions(-) diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index 709162c6a6..0e5b51ef2f 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -21,11 +21,22 @@ use std::sync::Arc; -use buzz_core::tenant::TenantContext; +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 { @@ -33,7 +44,7 @@ pub enum ModerationNotice { ReportResolved { /// The resolved report row. report_id: Uuid, - /// `resolved` | `dismissed` | `escalated`. + /// `resolved` | `dismissed`. status: String, /// Sanitized outcome line (no reporter/mod identities beyond policy). summary: String, @@ -59,12 +70,318 @@ pub enum ModerationNotice { /// Deliver a moderation notice to `recipient` in this community's /// relay-authored DM thread (created on first use, reused after). /// -/// Idempotent per (action/report id, recipient): re-delivery is a no-op. +/// 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, + tenant: &TenantContext, + state: &Arc, + recipient_pubkey: &[u8], + notice: ModerationNotice, ) -> anyhow::Result<()> { - todo!("L5 (Sami): DM channel reuse, 39000 discovery, relay-signed kind:9, kind:0 profile") + 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.")); + } } From 8a2fbdd343aeb744c6a6ef7de31bd095c5fb764f Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 13:50:25 -0400 Subject: [PATCH 08/24] moderation(L4): enforce bans/timeouts at the auth, ingest, and connection seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 community moderation enforcement lane (L4). Consumes L1's `Db::moderation_restriction_state` seam at three points: - Auth seam (handlers/auth.rs): reject AUTH from a banned member with `OK false "blocked: you are banned from this community"` and immediately close the socket (decision 4). The reason frame is routed over the control channel and the send loop drains it ahead of the Close, so the client learns why before the socket drops. Includes a NIP-OA owner cascade — an agent whose crypto-proven owner is banned is refused too. A DB error fails closed but denies with `error: internal ...` rather than falsely claiming a ban. - Ingest write-block (handlers/ingest.rs): a timed-out member's EVENTs are refused with `restricted: you are timed out until ` until the timeout expires. A ban is also re-checked here: an already-authenticated connection never re-auths, so if the live-disconnect fan-out is missed (fire-and-forget publish, broadcast lag, subscriber reconnect window), this write-path gate is the durable backstop that stops a banned member writing indefinitely. Moderation-command and relay-admin kinds are exempt so the tools that lift a restriction are never disarmed. Fails closed. The gate checks the authoring pubkey only; the NIP-OA cascade is structural at the auth seam for bans, and timeout's owner cascade is a documented Phase-1 asymmetry (IngestAuth carries no auth tag). - Live disconnect (state.rs): `ConnectionManager::disconnect_pubkey` closes every live socket for a pubkey *within the banning community*, delivering the close reason over the control channel. The community filter is the tenant fence: one pod holds sockets for many communities, so a ban in A must never kill the same member's session in B. Cross-pod fan-out (buzz-pubsub/src/conn_control.rs): a new Redis pub/sub channel carrying `ConnControl::DisconnectPubkey`, parallel to cache_invalidation rather than folded into it — a disconnect is an imperative, non-idempotent action, not a pure cache-key drop. main.rs spawns the subscriber and a consumer that applies inbound commands via disconnect_pubkey, passing the scoped community so the fence holds cross-pod. Co-authored-by: Dawn (sprout agent) Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-pubsub/src/conn_control.rs | 210 ++++++++++++++++++ crates/buzz-pubsub/src/lib.rs | 41 ++++ crates/buzz-relay/src/connection.rs | 51 +++++ crates/buzz-relay/src/handlers/auth.rs | 93 ++++++++ crates/buzz-relay/src/handlers/event.rs | 6 + crates/buzz-relay/src/handlers/ingest.rs | 53 +++++ .../buzz-relay/src/handlers/mesh_signaling.rs | 2 + crates/buzz-relay/src/main.rs | 45 ++++ crates/buzz-relay/src/state.rs | 165 +++++++++++++- 9 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-pubsub/src/conn_control.rs 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/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 b78838c3bc..929a5527c7 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1425,6 +1425,59 @@ async fn ingest_event_inner( return super::command_executor::handle_command(tenant, state, event, auth).await; } + // 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), 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/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/state.rs b/crates/buzz-relay/src/state.rs index a674e6d7c9..6ac127212a 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -33,6 +33,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 +72,7 @@ impl ConnectionManager { &self, conn_id: Uuid, tx: mpsc::Sender, + ctrl_tx: mpsc::Sender, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, @@ -78,6 +83,7 @@ impl ConnectionManager { conn_id, ConnEntry { tx, + ctrl_tx, cancel, community_id, backpressure_count, @@ -129,6 +135,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 @@ -744,36 +794,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 +841,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 +861,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 +903,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 +940,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 +967,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 +988,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" + ); + } } From 60cedbe16fac8f0ec5395395aea7875fde53eea6 Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 14:54:01 -0400 Subject: [PATCH 09/24] moderation(L4): pair live-ban disconnect with cross-pod fan-out on AppState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `AppState::disconnect_pubkey_clusterwide`, the single entry point for live ban enforcement: it closes this pod's sockets for the banned pubkey (fenced to the community) and spawns the cross-pod `publish_conn_control` fan-out so every other pod's subscriber closes its sockets too. Closes the wire-up gap Quinn found: #1594 shipped both halves as separate primitives (`conn_manager.disconnect_pubkey` + `PubSubManager:: publish_conn_control`) but nothing called the publish half, so a live ban was pod-local-only — violating decision 4's "immediately, everywhere, including live sessions". Rather than have the ban handler (L6) remember to call both, pairing them on AppState (mirroring `spawn_cache_invalidation`) makes "do half the job" unrepresentable: callers get cluster-wide enforcement from one call and can't silently drop the fan-out. The banning pod re-receives its own publish and no-ops (local sockets already closed) — intentional, documented, no origin-suppression. The return count is pod-local only; remote closes are async and unreported. Fire-and-forget publish with the DB ban row as the durable backstop. No dedicated unit test: pure composition of two already-tested primitives (the fenced pod-local disconnect has three tests incl. the tenant-fence case; publish + serde are covered in conn_control.rs). A full AppState + live-Redis harness for a 15-line pairing is disproportionate. Co-authored-by: Dawn (sprout agent) Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/state.rs | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 6ac127212a..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; @@ -656,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, From 324b4d13dcb930c0eea48cbff20474829f032207 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 15:04:51 -0400 Subject: [PATCH 10/24] moderation(L6): command handler (9040-9044) + mod-queue read endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 community moderation lane L6: the moderation command dispatch and the three HTTP read endpoints that back the mod queue. Command handler (handlers/moderation_commands.rs), kinds 9040-9044: - 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout, 9044 resolve-report. - Every command routes authorization through the single `authorize_moderation_action` capability helper (L2) — never an inline role check — then performs its DB write (L1), records the audit row, and best-effort sends the relay-signed notice DM (L5). - Freshness gate rejects stale/replayed commands (never stored). - 9040 ban drives live enforcement via the paired `AppState::disconnect_pubkey_clusterwide` (L4): one call closes this pod's fenced sockets and fans the disconnect out cross-pod, so a live ban takes effect immediately, everywhere (decision 4). The DB ban row is the durable backstop for a dropped fan-out. Read endpoints (api/bridge.rs) + routes (router.rs): - GET /moderation/reports, /moderation/audit, /moderation/restricted. - Each mirrors the existing count_events shape: HOST-bound tenant, NIP-98 auth + replay check, then the mod-authz gate (ViewQueue) rather than plain relay membership. Reads resolve tenant-scoped rows (L1) only. Depends on L2's `authorize_moderation_action` body, still `todo!()` and human-gated: the authz-gated paths compile against the pinned signature but panic at runtime until L2 lands. Handler unit tests cover the pure helpers (tag parse, expiration vocab, report-tag validation) which do not hit authz. Validation on base 0bff742f: fmt --check, check -p buzz-relay, clippy -p buzz-relay, test -p buzz-relay --lib all green (492 passed / 0 failed; 7 L6 handler tests included). git diff --check clean. Diff is three buzz-relay lane files only. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/api/bridge.rs | 172 +++++ .../src/handlers/moderation_commands.rs | 599 +++++++++++++++++- crates/buzz-relay/src/router.rs | 7 + 3 files changed, 774 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c32e80bbeb..8b96de11e3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1635,6 +1635,178 @@ 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. +async fn authorize_moderation_read( + state: &Arc, + headers: &HeaderMap, + path: &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 url = nip98_expected_url(&state.config.relay_url, &tenant, path); + 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, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = authorize_moderation_read(&state, &headers, "/moderation/reports").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, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = authorize_moderation_read(&state, &headers, "/moderation/audit").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").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::*; diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index ed026970a3..75f92d335d 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -53,18 +53,609 @@ 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, + 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(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(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(|| "missing or invalid p tag".to_string())?; + 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| 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(|| "missing or invalid p tag".to_string())?; + + 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| format!("database error: {e}"))?; + if !lifted { + return Err("member is not banned".to_string()); + } + + 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> { - todo!("L6 (Quinn): dispatch 9040–9044 through moderation_authz + buzz_db::moderation") + let target = + extract_p_tag_bytes(event).ok_or_else(|| "missing or invalid p tag".to_string())?; + let muted_until = extract_expiration(event)? + .ok_or_else(|| "timeout requires an expiration tag".to_string())?; + 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| 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(|| "missing or invalid p tag".to_string())?; + + 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| format!("database error: {e}"))?; + if !cleared { + return Err("member is not timed out".to_string()); + } + + 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(|| "missing or invalid report tag (expect 64-hex event id)".to_string())?; + let status = + extract_tag_value(event, "status").ok_or_else(|| "missing status tag".to_string())?; + let action = + extract_tag_value(event, "action").ok_or_else(|| "missing action tag".to_string())?; + 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(format!( + "invalid status: {status} (expect resolved|dismissed)" + )); + } + if !matches!( + action.as_str(), + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + )); + } + if (action == "dismiss") != (status == "dismissed") { + return Err("action `dismiss` pairs only with status `dismissed`".to_string()); + } + + 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| format!("database error: {e}"))? + .ok_or_else(|| "report not found in this community".to_string())?; + + // 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), + }; + + let audit_action = match action.as_str() { + "dismiss" => "dismiss_report", + "escalate" => "escalate", + other => other, // delete | kick | ban | timeout — fan out via existing paths + }; + 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| format!("database error: {e}"))?; + if !resolved { + return Err("report is not open (already resolved or dismissed)".to_string()); + } + + // 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 ──────────────────────────────────────────────────────────── + +/// 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| 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}") +} + +/// 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(|_| format!("invalid expiration tag: {raw}"))?; + match Utc.timestamp_opt(secs, 0).single() { + Some(ts) => Ok(Some(ts)), + None => Err(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 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/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 From 275c928ccc5cefe7b89180424d430b719be14342 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 15:22:30 -0400 Subject: [PATCH 11/24] moderation(L6): fix resolve-report audit races (Eva review #1599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small fixes in handle_resolve from Eva's #1599 review, plain delta on 8db239bb. F1 — orphan audit row on a lost resolve race: insert_audit ran before resolve_moderation_report, so two mods resolving the same report left an audit row behind the failed resolve. Check the already-fetched report.status == "open" before insert_audit and error early. The DB's WHERE status='open' stays the real guard; a comment notes the residual tiny window (audit row + failed resolve only) is tolerated. F2 — resolution rows indistinguishable from enforcement rows: a one-click resolve with action=ban wrote an audit row "ban", and the client's paired 9040 wrote a second "ban" enforcement row — double-count, decision-vs- enforcement ambiguity. Prefix the resolution decision row `resolve:ban`, `resolve:delete`, etc. dismiss_report and escalate stay unprefixed (escalate must remain queryable for the platform-safety lane). Module doc records the audit vocab. Validation on base 0bff742f (toolchain 1.95.0): fmt --check, clippy -p buzz-relay --all-targets -D warnings, test -p buzz-relay --lib all green (492 passed / 0 failed / 2 ignored). git diff --check clean. Delta is moderation_commands.rs only, +30/-2. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src/handlers/moderation_commands.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 75f92d335d..2ea049bbca 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -44,7 +44,11 @@ //! 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. +//! 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. +//! `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` + @@ -393,6 +397,17 @@ async fn handle_resolve( .map_err(|e| format!("database error: {e}"))? .ok_or_else(|| "report not found in this community".to_string())?; + // 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("report is not open (already resolved or dismissed)".to_string()); + } + // 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 { @@ -401,10 +416,23 @@ async fn handle_resolve( 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. Prefix the decision row (`resolve:ban`, `resolve:delete`, …) 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 resolve_audit; let audit_action = match action.as_str() { "dismiss" => "dismiss_report", "escalate" => "escalate", - other => other, // delete | kick | ban | timeout — fan out via existing paths + other => { + // delete | kick | ban | timeout — decision row; enforcement fans out + // via the client's paired 9040-9043 command. + resolve_audit = format!("resolve:{other}"); + resolve_audit.as_str() + } }; let action_id = insert_audit( state, From 23e8ebbb3ef34a929d2b19a5b913fd42897680aa Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:00 -0400 Subject: [PATCH 12/24] Phase-1 community moderation authorization seam (L2). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `authorize_moderation_action` (the capability helper, plan §1), the single seam every moderation decision routes through so a future Moderator tier is a policy change, not a rewrite (decision 1). Authority model: - Community owner/admin (tenant-scoped `relay_members.role`) authorize every action in any channel of their community — the bridge `validate_admin_event` is missing today. - Channel owner/admin keep channel-local authority for DeleteMessage/Kick within `channel_id`. No current call site passes a `channel_id` (every L6 handler + the queue bridge pass `None`), so this branch is the contract seam for the future `validate_admin_event` wiring — building it to contract now makes that a one-line consult, not a helper rewrite. - Guard rail: an admin cannot ban/timeout the community owner or a fellow admin; only the owner may action an admin. Scoped to ban/timeout only — restriction-lifting (unban/untimeout) is unguarded because a banned admin can't self-unban (blocked at the auth seam before any command runs), so the only reachable case is lifting a fellow admin's restriction, which is benign, audited, and owner-reversible. The guard trips on a target *role* of owner/admin, never on a missing row, so a drive-by spammer who already left is still bannable. Tenant fence: both role reads (`get_relay_member`, `get_member_role`) filter on `tenant.community()` in SQL — authority never crosses tenants. The policy is factored into a pure `decide_authority` from resolved roles, exhaustively unit-tested (7 tests: owner all-actions, admin against non-privileged and privileged targets, non-member bannable, guard scope, channel-role delete/kick-only, member/stranger denied). The async wrapper is thin I/O glue that reads only the roles a given path needs. Co-authored-by: Dawn (sprout agent) Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- .../src/handlers/moderation_authz.rs | 258 +++++++++++++++++- 1 file changed, 251 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 9047c0acc8..2f2781dd1d 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -81,12 +81,256 @@ pub enum ModerationAuthority { /// 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, + tenant: &TenantContext, + state: &Arc, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, ) -> anyhow::Result { - todo!("L2 (Mari): relay_members role + channel role lookup, owner>admin guard rails") + 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:?}" + ); + } + } } From 88c15dae31130edb4e67030b0c77a24c2a0dbef8 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 16:07:02 -0400 Subject: [PATCH 13/24] moderation(L6): verify moderation reads against full request URL incl. query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wren's L7 sweep (#1591) found a NIP-98 auth mismatch that 401'd `buzz moderation reports` and `audit` in normal use: the CLI signs the full request URL — query string included (`?limit=…&status=…`) — but the relay reconstructed the expected URL from the bare path only, and buzz-auth URL normalization preserves query strings, so they never matched. `restricted` was unaffected (no query). Fix: `authorize_moderation_read` now takes the request's raw query (axum `RawQuery`) and appends it verbatim to the path before building the NIP-98 expected URL, so the relay verifies against the actual request URI the client signed. Verbatim (not re-parsed) keeps the match byte-exact regardless of param order/encoding. `restricted` passes `None` and keeps its bare-path expectation. This is the direction Wren recommended: verifying the full request URL keeps future filtered reads honest. Regression coverage (4 tests, bridge.rs): a query-bearing GET verifies iff the expected URL carries the same query (`reports?limit=20&status=open`, `audit?limit=20`); an anti-regression control proves the same event is rejected with a URL mismatch against the bare path (the pre-fix behavior); and `restricted` still verifies query-less. A test-local helper mirrors the production query-reconstruction so the seam is pinned without a DB harness. Validation on base 80594133 (toolchain 1.95.0, PATH=repo bin): fmt --check, clippy -p buzz-relay --all-targets -D warnings, test -p buzz-relay --lib all green (503 passed / 0 failed / 2 ignored — 499 prior + 4 new). git diff --check clean. Delta is bridge.rs only, +157/-5. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/api/bridge.rs | 162 +++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8b96de11e3..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, }; @@ -1647,10 +1647,20 @@ async fn synthesize_presence( /// 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) @@ -1665,7 +1675,11 @@ async fn authorize_moderation_read( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, path); + 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?; @@ -1711,9 +1725,16 @@ fn clamp_limit(requested: Option) -> i64 { 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").await?; + let tenant = authorize_moderation_read( + &state, + &headers, + "/moderation/reports", + raw_query.as_deref(), + ) + .await?; let rows = state .db .list_moderation_reports( @@ -1730,9 +1751,12 @@ pub async fn moderation_reports( 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").await?; + 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)) @@ -1746,7 +1770,8 @@ pub async fn moderation_restricted( State(state): State>, headers: HeaderMap, ) -> Result, (StatusCode, Json)> { - let tenant = authorize_moderation_read(&state, &headers, "/moderation/restricted").await?; + let tenant = + authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; let rows = state .db .list_community_restrictions(tenant.community()) @@ -2099,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. From 0904c0238d24aaf320080fb3fd7af4e158c2d50a Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Tue, 7 Jul 2026 15:48:57 -0400 Subject: [PATCH 14/24] Route moderation ingest events Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- crates/buzz-db/src/migration.rs | 6 + crates/buzz-db/src/moderation.rs | 19 +- crates/buzz-relay/src/handlers/ingest.rs | 107 ++++++- .../src/handlers/moderation_commands.rs | 142 ++++++--- crates/buzz-relay/src/handlers/report.rs | 296 +++++++++++++++++- migrations/0006_moderation.sql | 4 +- 6 files changed, 511 insertions(+), 63 deletions(-) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 02fbc61e92..b255e79cbe 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -548,6 +548,12 @@ mod tests { .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")); } diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs index c8ca0f4c15..be8b712d45 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/moderation.rs @@ -99,13 +99,30 @@ pub struct BanRecord { 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` (DB CHECK-enforced). + /// | `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]>, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 929a5527c7..4b348a5205 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,11 +28,11 @@ use buzz_core::kind::{ 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_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; @@ -159,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 @@ -1425,6 +1433,41 @@ 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 ` @@ -2415,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 @@ -2438,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/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 2ea049bbca..4a74352b4c 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -47,6 +47,7 @@ //! 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). //! @@ -102,10 +103,10 @@ pub async fn handle_moderation_command( .map(|d| d.as_secs() as i64) .unwrap_or(0); if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { - return Err(format!( + 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 { @@ -114,7 +115,9 @@ pub async fn handle_moderation_command( 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(format!("unexpected moderation command kind: {other}")), + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), } } @@ -126,8 +129,7 @@ async fn handle_ban( event: &Event, actor: &[u8], ) -> Result<(), String> { - let target = - extract_p_tag_bytes(event).ok_or_else(|| "missing or invalid p tag".to_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"); @@ -152,7 +154,7 @@ async fn handle_ban( expires_at, ) .await - .map_err(|e| format!("database error: {e}"))?; + .map_err(|e| error(format!("database error: {e}")))?; let action_id = insert_audit( state, @@ -208,8 +210,7 @@ async fn handle_unban( event: &Event, actor: &[u8], ) -> Result<(), String> { - let target = - extract_p_tag_bytes(event).ok_or_else(|| "missing or invalid p tag".to_string())?; + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; authorize_moderation_action( tenant, @@ -226,9 +227,9 @@ async fn handle_unban( .db .unban_community_member(tenant.community(), &target, actor) .await - .map_err(|e| format!("database error: {e}"))?; + .map_err(|e| error(format!("database error: {e}")))?; if !lifted { - return Err("member is not banned".to_string()); + return Err(invalid("member is not banned")); } insert_audit(state, tenant, actor, "unban", Some(&target), None, None).await?; @@ -245,10 +246,9 @@ async fn handle_timeout( event: &Event, actor: &[u8], ) -> Result<(), String> { - let target = - extract_p_tag_bytes(event).ok_or_else(|| "missing or invalid p tag".to_string())?; - let muted_until = extract_expiration(event)? - .ok_or_else(|| "timeout requires an expiration tag".to_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( @@ -272,7 +272,7 @@ async fn handle_timeout( reason.as_deref(), ) .await - .map_err(|e| format!("database error: {e}"))?; + .map_err(|e| error(format!("database error: {e}")))?; let action_id = insert_audit( state, @@ -313,8 +313,7 @@ async fn handle_untimeout( event: &Event, actor: &[u8], ) -> Result<(), String> { - let target = - extract_p_tag_bytes(event).ok_or_else(|| "missing or invalid p tag".to_string())?; + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; authorize_moderation_action( tenant, @@ -331,9 +330,9 @@ async fn handle_untimeout( .db .untimeout_community_member(tenant.community(), &target, actor) .await - .map_err(|e| format!("database error: {e}"))?; + .map_err(|e| error(format!("database error: {e}")))?; if !cleared { - return Err("member is not timed out".to_string()); + return Err(invalid("member is not timed out")); } insert_audit(state, tenant, actor, "untimeout", Some(&target), None, None).await?; @@ -351,30 +350,30 @@ async fn handle_resolve( actor: &[u8], ) -> Result<(), String> { let report_event_id = extract_report_tag(event) - .ok_or_else(|| "missing or invalid report tag (expect 64-hex event id)".to_string())?; - let status = - extract_tag_value(event, "status").ok_or_else(|| "missing status tag".to_string())?; - let action = - extract_tag_value(event, "action").ok_or_else(|| "missing action tag".to_string())?; + .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(format!( + return Err(invalid(format!( "invalid status: {status} (expect resolved|dismissed)" - )); + ))); } if !matches!( action.as_str(), "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" ) { - return Err(format!( + return Err(invalid(format!( "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" - )); + ))); } if (action == "dismiss") != (status == "dismissed") { - return Err("action `dismiss` pairs only with status `dismissed`".to_string()); + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); } authorize_moderation_action( @@ -394,8 +393,8 @@ async fn handle_resolve( .db .get_moderation_report_by_event(tenant.community(), &report_event_id) .await - .map_err(|e| format!("database error: {e}"))? - .ok_or_else(|| "report not found in this community".to_string())?; + .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 @@ -405,7 +404,9 @@ async fn handle_resolve( // the DB write — but that window yields only an audit row plus a failed // resolve, which is tolerated. if report.status != "open" { - return Err("report is not open (already resolved or dismissed)".to_string()); + 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` @@ -419,21 +420,11 @@ async fn handle_resolve( // 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. Prefix the decision row (`resolve:ban`, `resolve:delete`, …) 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 resolve_audit; - let audit_action = match action.as_str() { - "dismiss" => "dismiss_report", - "escalate" => "escalate", - other => { - // delete | kick | ban | timeout — decision row; enforcement fans out - // via the client's paired 9040-9043 command. - resolve_audit = format!("resolve:{other}"); - resolve_audit.as_str() - } - }; + // 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, @@ -455,9 +446,11 @@ async fn handle_resolve( Some(action_id), ) .await - .map_err(|e| format!("database error: {e}"))?; + .map_err(|e| error(format!("database error: {e}")))?; if !resolved { - return Err("report is not open (already resolved or dismissed)".to_string()); + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); } // Close the loop: DM the reporter that their report was reviewed. @@ -486,6 +479,19 @@ async fn handle_resolve( // ── 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. @@ -515,7 +521,7 @@ async fn insert_audit( }, ) .await - .map_err(|e| format!("failed to write audit row: {e}")) + .map_err(|e| error(format!("failed to write audit row: {e}"))) } /// Map an authorization error to a client-safe `restricted:`-prefixed denial. @@ -523,6 +529,14 @@ 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() { @@ -561,10 +575,10 @@ fn extract_expiration(event: &Event) -> Result>, String> { Some(raw) => { let secs: i64 = raw .parse() - .map_err(|_| format!("invalid expiration tag: {raw}"))?; + .map_err(|_| invalid(format!("invalid expiration tag: {raw}")))?; match Utc.timestamp_opt(secs, 0).single() { Some(ts) => Ok(Some(ts)), - None => Err(format!("expiration out of range: {secs}")), + None => Err(invalid(format!("expiration out of range: {secs}"))), } } } @@ -607,6 +621,30 @@ mod tests { .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); diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index 22d8ae2eea..fccf8eb42b 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; use crate::state::AppState; @@ -41,9 +42,296 @@ pub const REPORT_TYPES: &[&str] = &[ /// 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, + tenant: &TenantContext, + event: &Event, + state: &Arc, ) -> Result<(), String> { - todo!("L3 (Perci): validate report-type tag, tenant-fenced target resolution, insert_report") + 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/migrations/0006_moderation.sql b/migrations/0006_moderation.sql index 5de8a7ac9a..17656973e7 100644 --- a/migrations/0006_moderation.sql +++ b/migrations/0006_moderation.sql @@ -97,7 +97,9 @@ CREATE TABLE moderation_actions ( actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), action TEXT NOT NULL CHECK (action IN ( 'delete_message', 'kick', 'ban', 'unban', - 'timeout', 'untimeout', 'dismiss_report', 'escalate')), + 'timeout', 'untimeout', 'dismiss_report', 'escalate', + 'resolve:delete', 'resolve:kick', 'resolve:ban', + 'resolve:timeout')), target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), channel_id UUID, From 67ee76d7e6f26d241522dbe2bc987c9c7f385256 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 13:34:42 -0400 Subject: [PATCH 15/24] =?UTF-8?q?moderation:=20buzz-cli=20moderation=20com?= =?UTF-8?q?mand=20group=20+=20SDK=209040=E2=80=939044=20builders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L6 (Quinn), CLI half. Adds the `buzz moderation` command group and the NIP-43-style community moderation command builders it signs. buzz-sdk builders (kinds 9040–9044), tag vocabulary pinned by moderation_commands.rs: - build_moderation_ban (9040): p + optional expiration + reason - build_moderation_unban (9041): p - build_moderation_timeout (9042): p + required expiration + optional reason - build_moderation_untimeout (9043): p - build_moderation_resolve_report (9044): report + status + action + optional reason; validates status/action vocabulary at build time buzz-cli `moderation` group: - Mutations (ban/unban/timeout/untimeout/resolve) sign 9040–9044 and POST /events, mirroring the 9030-series relay-admin transport. Community is host-scoped, so no channel/community flag. - Reads (reports/restricted/audit) GET NIP-98-authed /moderation/* endpoints, because queue/audit rows are structured DB rows, not stored nostr events — serving them over a REQ filter would mean synthesizing fake events and threading a privileged authz check into the public read path. Read-endpoint server side pending Eva's lane arbitration; CLI shape is stable either way. - client.get_authed() helper for NIP-98 GETs. Tests: 12 SDK builder tests (tag shape, pubkey lowercasing, vocab rejection); command-inventory stability updated for the new group. cargo check + clippy clean; buzz-cli + buzz-sdk suites green. Contract base: 4c58489b on eva/community-moderation. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-cli/src/client.rs | 14 ++ crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/commands/moderation.rs | 165 ++++++++++++++++ crates/buzz-cli/src/lib.rs | 114 +++++++++++ crates/buzz-sdk/src/builders.rs | 210 ++++++++++++++++++++- 5 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-cli/src/commands/moderation.rs 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/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..0b9061937c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -213,6 +213,9 @@ enum Cmd { /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), + /// Community moderation — reports queue, bans, timeouts, audit trail + #[command(subcommand)] + Moderation(ModerationCmd), } #[derive(Subcommand)] @@ -1408,6 +1411,102 @@ pub enum PackCmd { }, } +/// Community moderation commands. +/// +/// The community (tenant) is selected by the relay host in `--relay` / +/// `BUZZ_RELAY_URL` — moderation commands are community-global and carry no +/// channel scope. The signing key must be a community owner/admin; the relay +/// authorizes every command. +#[derive(Subcommand)] +pub enum ModerationCmd { + /// List reports in the moderation queue (newest first) + #[command( + after_help = "Examples:\n buzz moderation reports\n buzz moderation reports --status open --limit 20" + )] + Reports { + /// Filter by status: open | resolved | dismissed | escalated (default: all) + #[arg(long)] + status: Option, + /// 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 +1562,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, + Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), } } @@ -1489,6 +1589,7 @@ mod tests { "issues", "mem", "messages", + "moderation", "notes", "pack", "patches", @@ -1620,6 +1721,19 @@ mod tests { ); assert_eq!(names(&cmd, "upload"), vec!["file"]); assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]); + assert_eq!( + names(&cmd, "moderation"), + vec![ + "audit", + "ban", + "reports", + "resolve", + "restricted", + "timeout", + "unban", + "untimeout" + ] + ); } #[test] diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 5cb2bf118c..051c601181 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, @@ -1511,6 +1513,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 { + check_hex_len(target_pubkey, 64, "target_pubkey")?; + let mut tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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 { + check_hex_len(target_pubkey, 64, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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 { + check_hex_len(target_pubkey, 64, "target_pubkey")?; + let mut tags = vec![ + tag(&["p", &target_pubkey.to_ascii_lowercase()])?, + 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 { + check_hex_len(target_pubkey, 64, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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 { + check_hex_len(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.to_ascii_lowercase()])?, + 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::*; @@ -3134,4 +3241,105 @@ 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::InvalidDiffMeta(_))); + } + + #[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::InvalidDiffMeta(_))); + } } From e295384506e632e0e63bcab4de4d3b8e6497a72d Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 16:42:45 -0400 Subject: [PATCH 16/24] moderation: SDK builders enforce exact-64-hex for p/report tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wren's L7 re-clear found that build_moderation_{ban,unban,timeout, untimeout,resolve_report} validated hex targets with check_hex_len(.., 64), which is a *minimum*-length check (built for abbreviated git SHAs). The relay's extractors (moderation_commands.rs extract_p_tag_bytes / extract_report_tag) require exactly 64 hex. The CLI masked this by pre-validating with validate_hex64, but a direct SDK caller could build and sign a p/report tag with 65+ hex chars that the relay then rejects as missing/invalid — the SDK vouching for an event the relay drops. Switch to the existing exact-length helpers: check_pubkey_hex for the four target pubkeys and check_hex_exact(.., 64, ..) for the report id. Both return the normalized lowercase value, so the redundant inline .to_ascii_lowercase() on the tag values goes away. Error variant for short/overlong input moves from InvalidDiffMeta to InvalidInput accordingly. Tests: add overlong-hex regressions for a target pubkey and the report id; update the two existing short-input tests to the InvalidInput variant. buzz-sdk 219 / buzz-cli 135, clippy + fmt clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-sdk/src/builders.rs | 41 +++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 051c601181..f3d365d431 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1532,8 +1532,8 @@ pub fn build_moderation_ban( expires_at: Option, reason: Option<&str>, ) -> Result { - check_hex_len(target_pubkey, 64, "target_pubkey")?; - let mut tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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()])?); } @@ -1545,8 +1545,8 @@ pub fn build_moderation_ban( /// Build a community unban command (kind 9041). pub fn build_moderation_unban(target_pubkey: &str) -> Result { - check_hex_len(target_pubkey, 64, "target_pubkey")?; - let tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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)) } @@ -1558,9 +1558,9 @@ pub fn build_moderation_timeout( expires_at: u64, reason: Option<&str>, ) -> Result { - check_hex_len(target_pubkey, 64, "target_pubkey")?; + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; let mut tags = vec![ - tag(&["p", &target_pubkey.to_ascii_lowercase()])?, + tag(&["p", &target_pubkey])?, tag(&["expiration", &expires_at.to_string()])?, ]; if let Some(r) = reason { @@ -1571,8 +1571,8 @@ pub fn build_moderation_timeout( /// Build a community untimeout command (kind 9043). pub fn build_moderation_untimeout(target_pubkey: &str) -> Result { - check_hex_len(target_pubkey, 64, "target_pubkey")?; - let tags = vec![tag(&["p", &target_pubkey.to_ascii_lowercase()])?]; + 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)) } @@ -1590,7 +1590,7 @@ pub fn build_moderation_resolve_report( action: &str, reason: Option<&str>, ) -> Result { - check_hex_len(report_event_id, 64, "report_event_id")?; + let report_event_id = check_hex_exact(report_event_id, 64, "report_event_id")?; match status { "resolved" | "dismissed" => {} _ => { @@ -1608,7 +1608,7 @@ pub fn build_moderation_resolve_report( } } let mut tags = vec![ - tag(&["report", &report_event_id.to_ascii_lowercase()])?, + tag(&["report", &report_event_id])?, tag(&["status", status])?, tag(&["action", action])?, ]; @@ -3273,7 +3273,15 @@ mod tests { #[test] fn moderation_ban_rejects_short_pubkey() { let err = build_moderation_ban("abc", None, None).unwrap_err(); - assert!(matches!(err, SdkError::InvalidDiffMeta(_))); + 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] @@ -3340,6 +3348,15 @@ mod tests { #[test] fn moderation_resolve_rejects_short_report_id() { let err = build_moderation_resolve_report("abc", "resolved", "ban", None).unwrap_err(); - assert!(matches!(err, SdkError::InvalidDiffMeta(_))); + 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(_))); } } From ec34ca37ecde09e78b324c134f323ae37a259b20 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 18:02:56 -0400 Subject: [PATCH 17/24] schema: fold 0006_moderation into schema.sql The pgschema provisioning path (scripts/start-*-relay.sh, fresh-DB CI) applies schema/schema.sql only and is blind to migrations/. Without this fold, freshly provisioned DBs lack moderation_reports/community_bans/ moderation_actions and kind-1984 ingest 500s (hit live during the Phase-1 demo). Verbatim body of migrations/0006_moderation.sql; verified by pgschema apply on a fresh DB + zero-diff pgschema plan against an AUTO_MIGRATE-built DB. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- schema/schema.sql | 122 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/schema/schema.sql b/schema/schema.sql index 36deebfca3..e31347d726 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -606,6 +606,128 @@ CREATE TABLE audit_log ( CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash); +-- ── NIP-56 reports (kind:1984 ingest) ───────────────────────────────────────── +-- One row per accepted report event. Reports are signals, never triggers: +-- nothing auto-actions on them (NIP-56). Reporter identity is visible to +-- moderators in the queue but never revealed to the reported author. + +CREATE TABLE moderation_reports ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + -- The signed kind:1984 event id (stored for audit/idempotency). + report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32), + reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32), + -- What was reported. Exactly one target class per row (CHECK-enforced below). + target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32), + -- Channel inferred from an in-tenant target event row, when resolvable. + channel_id UUID, + -- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other. + report_type TEXT NOT NULL, + -- Reporter's optional free-text context (mod-queue-only; never public). + note TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')), + resolved_by BYTEA, + resolved_at TIMESTAMPTZ, + -- moderation_actions row that resolved this report, if any. + action_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + -- Exactly one target class per row: target_kind is authoritative and the + -- matching column (only) is populated. Queue/action code never guesses. + CHECK ( + (target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR + (target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL) + ), + -- Same-community channel provenance (channels are soft-deleted, never + -- hard-deleted, so this FK cannot dangle). + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +-- Queue reads: open reports, newest first, per community. +CREATE INDEX idx_moderation_reports_status + ON moderation_reports (community_id, status, created_at DESC); +-- Group-by-target for triage aggregation. +CREATE INDEX idx_moderation_reports_target_event + ON moderation_reports (community_id, target_event_id) + WHERE target_event_id IS NOT NULL; +CREATE INDEX idx_moderation_reports_target_pubkey + ON moderation_reports (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; +-- Idempotency: one row per report event per community. +CREATE UNIQUE INDEX idx_moderation_reports_event + ON moderation_reports (community_id, report_event_id); + +-- ── Bans + timeouts (one restriction row per member) ────────────────────────── +-- Ban = connection block, enforced at the NIP-42 auth seam +-- ("blocked: you are banned from this community") + join/ingest surfaces. +-- Timeout = write-block only ("restricted: you are timed out until "). +-- A row may be ban-only, timeout-only, or both over its lifetime. + +CREATE TABLE community_bans ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), + banned BOOLEAN NOT NULL DEFAULT false, + -- NULL + banned=true ⇒ permanent. + ban_expires_at TIMESTAMPTZ, + ban_reason TEXT, + -- Write-block until this timestamp; NULL or past ⇒ not timed out. + muted_until TIMESTAMPTZ, + mute_reason TEXT, + -- Moderator who last modified this row. + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) +); + +-- ── Moderation audit ────────────────────────────────────────────────────────── +-- One row per accepted moderation action. Full detail (reporter identities, +-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public +-- tombstone carries only action_id + reason_code + sanitized public_reason. + +CREATE TABLE moderation_actions ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + action TEXT NOT NULL CHECK (action IN ( + 'delete_message', 'kick', 'ban', 'unban', + 'timeout', 'untimeout', 'dismiss_report', 'escalate', + 'resolve:delete', 'resolve:kick', 'resolve:ban', + 'resolve:timeout')), + target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), + target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32), + channel_id UUID, + -- Machine-readable rule/reason code (e.g. "spam", "community_rule_3"). + reason_code TEXT, + -- Sanitized, safe for the public tombstone. + public_reason TEXT, + -- Mod-only context; never leaves the audit surface. + private_reason TEXT, + -- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only, + -- the client never learns which. + matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) +); + +CREATE INDEX idx_moderation_actions_created + ON moderation_actions (community_id, created_at DESC); +CREATE INDEX idx_moderation_actions_target_pubkey + ON moderation_actions (community_id, target_pubkey) + WHERE target_pubkey IS NOT NULL; + +-- Same-community resolution provenance: a report can only be resolved by an +-- action row in its own community. Added after moderation_actions exists. +ALTER TABLE moderation_reports + ADD FOREIGN KEY (community_id, action_id) + REFERENCES moderation_actions (community_id, id); + -- ── Lint allowlist registry ─────────────────────────────────────────────────── -- The explicit registry of tables that are deliberately operator-global (NOT -- tenant-scoped). The migration-lint harness reads this: any table NOT listed From 9584ba74ea19bec674f8c816a9a32da4253f1f09 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 18:03:03 -0400 Subject: [PATCH 18/24] relay(nip11): advertise NIP-56 in supported_nips kind:1984 report ingest is live since the Phase-1 moderation merge but the NIP-11 document didn't advertise it, so interop clients wouldn't discover report support (Quinn's e2e cap note). Adds 56 to SUPPORTED_NIPS with a mirror test. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/nip11.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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); From 942a214fbc7b9d758c266d6a15f28f040fb07005 Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 18:17:59 -0400 Subject: [PATCH 19/24] =?UTF-8?q?feat(desktop):=20moderation=20data=20laye?= =?UTF-8?q?r=20=E2=80=94=20shared/api=20+=20feature=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Phase-2 community-moderation UI (U1/U2 both consume it). Adds the client-side wire contract with no new relay surface: - shared/api/moderation.ts: signed command events (report 1984, ban/unban/ timeout/untimeout 9040-9043, resolve 9044) published over the existing WS path (mirrors relayMembers.ts); NIP-98-authed HTTP GET reader for the three /moderation/* reads (reports, audit, restricted). Command events carry no h tag — the relay binds the tenant from the connection host. The NIP-98 u tag is signed over the fully-finalized URL (query included) so the signed u and the fetched URL are always identical. - features/moderation/hooks.ts: React Query hooks over that API, mutations invalidate the affected read caches on success (mirrors relay-members). - constants/kinds.ts: KIND_REPORT + KIND_MODERATION_* kinds. Tag shapes are pinned by buzz-sdk builders, report.rs, and api/bridge.rs. Validated: tsc --noEmit clean, biome check clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/features/moderation/hooks.ts | 153 +++++++++ desktop/src/shared/api/moderation.ts | 375 +++++++++++++++++++++++ desktop/src/shared/constants/kinds.ts | 9 + 3 files changed, 537 insertions(+) create mode 100644 desktop/src/features/moderation/hooks.ts create mode 100644 desktop/src/shared/api/moderation.ts diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts new file mode 100644 index 0000000000..f196f32528 --- /dev/null +++ b/desktop/src/features/moderation/hooks.ts @@ -0,0 +1,153 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +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; + +// --- 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/shared/api/moderation.ts b/desktop/src/shared/api/moderation.ts new file mode 100644 index 0000000000..1cc0d7da49 --- /dev/null +++ b/desktop/src/shared/api/moderation.ts @@ -0,0 +1,375 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, + KIND_REPORT, +} from "@/shared/constants/kinds"; + +// Community-moderation data layer. Writes are signed Nostr events published over +// the same WebSocket path as every other desktop write (mirrors relayMembers.ts); +// reads are NIP-98-authed HTTP GETs to /moderation/*, which have no WS equivalent. +// +// Wire contract is pinned by the relay/CLI (buzz-sdk builders, report.rs, +// api/bridge.rs). Command events (9040–9044) carry NO `h` tag — the relay binds +// the tenant from the connection host, and a stray `h` is rejected as +// channel-scoping a global-only command. + +const NIP98_KIND = 27235; + +/** NIP-56 report categories (report.rs `REPORT_TYPES`). */ +export type ReportType = + | "illegal" + | "nudity" + | "malware" + | "spam" + | "impersonation" + | "profanity" + | "other"; + +/** A moderator's disposition of a queued report (buzz-sdk resolve builder). */ +export type ResolutionStatus = "resolved" | "dismissed"; +export type ResolutionAction = + | "delete" + | "kick" + | "ban" + | "timeout" + | "dismiss" + | "escalate"; + +// --- Read row shapes (api/bridge.rs report_json / action_json / ban_json) --- + +export type ModerationReport = { + id: number; + reportEventId: string; + reporterPubkey: string; + targetKind: "event" | "pubkey" | "blob"; + target: string; + channelId: string | null; + reportType: string; + note: string | null; + status: string; + resolvedBy: string | null; + resolvedAt: number | null; + actionId: string | null; + createdAt: number; +}; + +export type ModerationAction = { + id: number; + actorPubkey: string; + action: string; + targetPubkey: string | null; + targetEventId: string | null; + channelId: string | null; + reasonCode: string | null; + publicReason: string | null; + privateReason: string | null; + matchedPrincipal: string | null; + createdAt: number; +}; + +export type CommunityRestriction = { + pubkey: string; + banned: boolean; + banExpiresAt: number | null; + banReason: string | null; + mutedUntil: number | null; + muteReason: string | null; + actorPubkey: string; + updatedAt: number; +}; + +function normalizePubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +// --- Writes: signed events over the WS publish path --- + +async function publishModerationEvent( + kind: number, + tags: string[][], + timeoutMessage: string, + errorMessage: string, +): Promise { + const event = await signRelayEvent({ kind, content: "", tags }); + await relayClient.publishEvent(event, timeoutMessage, errorMessage); +} + +/** + * Submit a NIP-56 report (kind:1984). Member-facing report entry always targets + * a message, so both the author `p` tag and the `e` (event) tag are carried; the + * report type rides the `e` tag's third element per report.rs `parse_report`. + */ +export async function submitReport(input: { + authorPubkey: string; + eventId: string; + reportType: ReportType; + note?: string; +}): Promise { + const tags: string[][] = [ + ["p", normalizePubkey(input.authorPubkey)], + ["e", input.eventId, input.reportType], + ]; + const event = await signRelayEvent({ + kind: KIND_REPORT, + content: input.note?.trim() ? input.note.trim() : "", + tags, + }); + await relayClient.publishEvent( + event, + "Timed out while submitting the report.", + "Failed to submit the report.", + ); +} + +/** Ban a member (kind:9040). `expiresAt` unix-secs ⇒ temporary; omit ⇒ permanent. */ +export async function banMember(input: { + pubkey: string; + expiresAt?: number; + reason?: string; +}): Promise { + const tags: string[][] = [["p", normalizePubkey(input.pubkey)]]; + if (input.expiresAt != null) + tags.push(["expiration", String(input.expiresAt)]); + if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]); + await publishModerationEvent( + KIND_MODERATION_BAN, + tags, + "Timed out while banning the member.", + "Failed to ban the member.", + ); +} + +/** Lift a ban (kind:9041). */ +export async function unbanMember(pubkey: string): Promise { + await publishModerationEvent( + KIND_MODERATION_UNBAN, + [["p", normalizePubkey(pubkey)]], + "Timed out while lifting the ban.", + "Failed to lift the ban.", + ); +} + +/** Time out a member (kind:9042). `expiresAt` unix-secs is required by the relay. */ +export async function timeoutMember(input: { + pubkey: string; + expiresAt: number; + reason?: string; +}): Promise { + const tags: string[][] = [ + ["p", normalizePubkey(input.pubkey)], + ["expiration", String(input.expiresAt)], + ]; + if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]); + await publishModerationEvent( + KIND_MODERATION_TIMEOUT, + tags, + "Timed out while applying the timeout.", + "Failed to apply the timeout.", + ); +} + +/** Lift a timeout (kind:9043). */ +export async function untimeoutMember(pubkey: string): Promise { + await publishModerationEvent( + KIND_MODERATION_UNTIMEOUT, + [["p", normalizePubkey(pubkey)]], + "Timed out while lifting the timeout.", + "Failed to lift the timeout.", + ); +} + +/** + * Resolve a queued report (kind:9044). `dismiss` pairs with `dismissed`; every + * other action pairs with `resolved` (the relay enforces the pairing). `reason` + * is moderator-authored and reporter-readable — it lands in the public tombstone + * and the reporter-notice DM. + */ +export async function resolveReport(input: { + reportEventId: string; + status: ResolutionStatus; + action: ResolutionAction; + reason?: string; +}): Promise { + const tags: string[][] = [ + ["report", normalizePubkey(input.reportEventId)], + ["status", input.status], + ["action", input.action], + ]; + if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]); + await publishModerationEvent( + KIND_MODERATION_RESOLVE_REPORT, + tags, + "Timed out while resolving the report.", + "Failed to resolve the report.", + ); +} + +// --- Reads: NIP-98-authed HTTP GETs --- + +/** + * Build the NIP-98 `Authorization` header for a GET. + * + * The relay verifies the signed `u` tag against the full request URL including + * the query string (the read-auth fix), so the URL is finalized by the caller + * *before* signing and this function never appends parameters afterward — the + * signed `u` and the fetched URL are guaranteed identical. + */ +async function nip98GetHeader(url: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "GET"], + ["nonce", crypto.randomUUID()], + ], + }); + // NIP-98 events carry empty content and ASCII-only tags, so btoa is safe here. + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +async function moderationGet(pathWithQuery: string): Promise { + const base = (await getRelayHttpUrl()).replace(/\/+$/, ""); + const url = `${base}${pathWithQuery}`; + const authorization = await nip98GetHeader(url); + const response = await fetch(url, { + headers: { Authorization: authorization }, + }); + if (!response.ok) { + throw new Error( + `Moderation request failed (${response.status}): ${pathWithQuery}`, + ); + } + return (await response.json()) as T; +} + +type RawReport = { + id: number; + report_event_id: string; + reporter_pubkey: string; + target_kind: "event" | "pubkey" | "blob"; + target: string; + channel_id: string | null; + report_type: string; + note: string | null; + status: string; + resolved_by: string | null; + resolved_at: number | null; + action_id: string | null; + created_at: number; +}; + +type RawAction = { + id: number; + actor_pubkey: string; + action: string; + target_pubkey: string | null; + target_event_id: string | null; + channel_id: string | null; + reason_code: string | null; + public_reason: string | null; + private_reason: string | null; + matched_principal: string | null; + created_at: number; +}; + +type RawRestriction = { + pubkey: string; + banned: boolean; + ban_expires_at: number | null; + ban_reason: string | null; + muted_until: number | null; + mute_reason: string | null; + actor_pubkey: string; + updated_at: number; +}; + +function toReport(r: RawReport): ModerationReport { + return { + id: r.id, + reportEventId: r.report_event_id, + reporterPubkey: r.reporter_pubkey, + targetKind: r.target_kind, + target: r.target, + channelId: r.channel_id, + reportType: r.report_type, + note: r.note, + status: r.status, + resolvedBy: r.resolved_by, + resolvedAt: r.resolved_at, + actionId: r.action_id, + createdAt: r.created_at, + }; +} + +function toAction(a: RawAction): ModerationAction { + return { + id: a.id, + actorPubkey: a.actor_pubkey, + action: a.action, + targetPubkey: a.target_pubkey, + targetEventId: a.target_event_id, + channelId: a.channel_id, + reasonCode: a.reason_code, + publicReason: a.public_reason, + privateReason: a.private_reason, + matchedPrincipal: a.matched_principal, + createdAt: a.created_at, + }; +} + +function toRestriction(b: RawRestriction): CommunityRestriction { + return { + pubkey: b.pubkey, + banned: b.banned, + banExpiresAt: b.ban_expires_at, + banReason: b.ban_reason, + mutedUntil: b.muted_until, + muteReason: b.mute_reason, + actorPubkey: b.actor_pubkey, + updatedAt: b.updated_at, + }; +} + +/** + * Fetch the moderation queue (`GET /moderation/reports`). Mod-authz gated — + * ordinary members receive 403. `status` filters (e.g. "open"); `limit` is + * clamped relay-side. + */ +export async function listReports(options?: { + status?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.limit != null) params.set("limit", String(options.limit)); + if (options?.status) params.set("status", options.status); + const query = params.toString(); + const rows = await moderationGet( + query ? `/moderation/reports?${query}` : "/moderation/reports", + ); + return rows.map(toReport); +} + +/** Fetch the audit log (`GET /moderation/audit`), newest-first. Mod-authz gated. */ +export async function listAuditActions( + limit?: number, +): Promise { + const query = limit != null ? `?limit=${limit}` : ""; + const rows = await moderationGet(`/moderation/audit${query}`); + return rows.map(toAction); +} + +/** + * Fetch active bans/timeouts (`GET /moderation/restricted`). Mod-authz gated — + * this is the moderator's view of who is currently restricted, not a member's + * self-state lookup. + */ +export async function listRestrictions(): Promise { + const rows = await moderationGet("/moderation/restricted"); + return rows.map(toRestriction); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 8b6c8dca6d..8b16a89886 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -5,6 +5,15 @@ export const KIND_STREAM_MESSAGE = 9; // Buzz-native deletion. The relay soft-deletes the target and emits a // kind:40099 system message. Treated as a deletion marker alongside kind:5. export const KIND_NIP29_DELETE_EVENT = 9005; +// NIP-56 report + community-moderation command kinds. Reports (1984) persist to +// the mod queue only; commands (9040–9044) are relay-validated and never stored. +// Tag shapes are pinned by buzz-sdk builders + relay moderation_commands.rs. +export const KIND_REPORT = 1984; +export const KIND_MODERATION_BAN = 9040; +export const KIND_MODERATION_UNBAN = 9041; +export const KIND_MODERATION_TIMEOUT = 9042; +export const KIND_MODERATION_UNTIMEOUT = 9043; +export const KIND_MODERATION_RESOLVE_REPORT = 9044; export const KIND_STREAM_MESSAGE_V2 = 40002; export const KIND_STREAM_MESSAGE_EDIT = 40003; export const KIND_CHANNEL_THREAD_SUMMARY = 39005; From 05749d92b679878a4c92157bba953b7318c37c86 Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 18:33:53 -0400 Subject: [PATCH 20/24] =?UTF-8?q?feat(desktop):=20moderation=20member=20su?= =?UTF-8?q?rface=20=E2=80=94=20report=20modal,=20tombstone,=20timeout=20pa?= =?UTF-8?q?rser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member-facing U1 slice on top of the shared foundation (06572210): - features/moderation/ui/ReportMessageDialog.tsx + a "Report message" entry in MessageActionBar's more-menu. NIP-56 category picker (spam/profanity/ nudity/impersonation/malware/illegal/other) + optional note, submits via useSubmitReportMutation, toast on success. Gated to real delivered messages (hidden on pending sends and huddle system rows). Mirrors the existing delete-dialog pattern; self-contained via the hook, no new props from MessageRow. - SystemMessageRow: render the kind:40099 message_deleted tombstone. When a moderator removed the message the relay stamps a sanitized public_reason, shown as "Removed by community moderators — "; a plain self-delete carries none and reads " removed a message". public_reason/reason_code/ action_id added as optional payload fields. Content and reporter never shown. - features/moderation/lib/timeout.ts (+ tests): parse the reactive timeout contract `restricted: you are timed out until ` from a send rejection. Defensive — the prefix identifies the timeout; a bad/absent timestamp yields a chip-without-countdown rather than a false negative. Also fixes a type-lie in the shared reader: /moderation/* JSON carries `id` as a UUID string and every timestamp as an RFC3339 string (bridge.rs serializes DateTime/Uuid directly, no numeric conversion), so the Raw* and exported Moderation* types flip from number to string. Caught by Sami. Validated: tsc --noEmit clean, biome clean, timeout tests 6/6 green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../features/messages/ui/MessageActionBar.tsx | 57 +++++-- .../features/messages/ui/SystemMessageRow.tsx | 21 +++ .../features/moderation/lib/timeout.test.mjs | 54 +++++++ .../src/features/moderation/lib/timeout.ts | 66 +++++++++ .../moderation/ui/ReportMessageDialog.tsx | 139 ++++++++++++++++++ desktop/src/shared/api/moderation.ts | 32 ++-- 6 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 desktop/src/features/moderation/lib/timeout.test.mjs create mode 100644 desktop/src/features/moderation/lib/timeout.ts create mode 100644 desktop/src/features/moderation/ui/ReportMessageDialog.tsx diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index d483ab2219..0cb4c6814b 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,7 @@ 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 type { TimelineMessage, TimelineReaction, @@ -89,6 +91,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 +105,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 +239,31 @@ function MoreActionsMenu({ ) : null} + {canReport || onDelete ? : null} + + {canReport ? ( + { + setIsReportDialogOpen(true); + }} + > + + Report message + + ) : null} + {onDelete ? ( - <> - - { - setIsDeleteDialogOpen(true); - }} - > - - Delete message - - + { + setIsDeleteDialogOpen(true); + }} + > + + Delete message + ) : null} @@ -277,6 +299,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/lib/timeout.test.mjs b/desktop/src/features/moderation/lib/timeout.test.mjs new file mode 100644 index 0000000000..c85e0b8416 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isTimeoutActive, parseTimeoutRejection } 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); +}); diff --git a/desktop/src/features/moderation/lib/timeout.ts b/desktop/src/features/moderation/lib/timeout.ts new file mode 100644 index 0000000000..a827e531b1 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.ts @@ -0,0 +1,66 @@ +/** + * 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"; + +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; +} 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) => ( + + ))} +
+ +
+ +