From 8db239bb08abaefd509b0c5eddc635691e6c1279 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 1/2] 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 e4823614ba6aa84cd0c9710918d68bc35e6fd5f2 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 2/2] 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,