diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4..060771cbff 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -286,15 +286,15 @@ async fn cmd_list_members() -> Result { Ok(0) } -/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`. +/// Validate that `role` is `"member"`, `"moderator"`, or `"admin"`. Rejects `"owner"`. fn validate_role(role: &str) -> std::result::Result<(), String> { match role { - "member" | "admin" => Ok(()), + "member" | "moderator" | "admin" => Ok(()), "owner" => { Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string()) } other => Err(format!( - "invalid role '{other}': must be 'member' or 'admin'" + "invalid role '{other}': must be 'member', 'moderator', or 'admin'" )), } } diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..e16be972df 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -635,6 +635,80 @@ pub async fn remove_member( Ok(()) } +/// Removes a channel member as an authorized community moderator, bypassing the +/// channel-role requirement of [`remove_member`]. +/// +/// This is the **sole** call site for moderator-initiated kicks (kind 9001 +/// third-party removal). It must only be invoked after +/// `authorize_moderation_action(..., Kick)` has succeeded — the function name +/// is the contract; there is no `skip_auth` boolean. +/// +/// Acquires the same per-channel membership lock as [`remove_member`] so the +/// last-owner check and the soft-deletion are serialized against concurrent +/// membership writes. Re-checks target existence and last-channel-owner +/// protection inside the transaction. The shared post-mutation path (cache +/// invalidation, subscription eviction, etc.) must fire in the caller after +/// this returns `Ok`. +/// +/// Returns: +/// - `Ok(())` — member was soft-deleted. +/// - `Err(DbError::MemberNotFound)` — target is not an active member. +/// - `Err(DbError::AccessDenied)` — target is the last channel owner. +pub async fn remove_member_as_community_moderator( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], +) -> Result<()> { + let mut tx = pool.begin().await?; + + // Serialize the last-owner check and the soft-delete against concurrent + // membership writes on this channel (same advisory key as `remove_member`). + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + + // Defense-in-depth: prevent removing the last owner regardless of the + // community-level authority the caller holds. + let target_role = get_active_role_tx(&mut tx, community_id, channel_id, target_pubkey).await?; + if target_role.as_deref() == Some("owner") { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&mut *tx) + .await?; + let owner_count: i64 = row.try_get("cnt")?; + if owner_count <= 1 { + return Err(DbError::AccessDenied( + "cannot remove the last owner — transfer ownership first".to_string(), + )); + } + } + + let result = sqlx::query( + r#" + UPDATE channel_members + SET removed_at = NOW(), removed_by = $1 + WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL + "#, + ) + .bind(actor_pubkey) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(target_pubkey) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::MemberNotFound(channel_id)); + } + + tx.commit().await?; + Ok(()) +} + /// Returns `true` if the given pubkey is an active member of the channel. pub async fn is_member( pool: &PgPool, @@ -2684,4 +2758,182 @@ mod tests { .expect("read role after restore"); assert_eq!(restored.as_deref(), Some("owner")); } + + // ── remove_member_as_community_moderator ────────────────────────────────── + + /// A relay-level moderator can kick an ordinary channel member even when + /// the moderator holds no channel role. The test exercises the DB mutation + /// path directly to verify that: (a) the member is removed, (b) the last- + /// owner guard fires when needed, and (c) a non-member target returns + /// `MemberNotFound`. + /// + /// Discriminating: fails if the function falls back to the standard + /// `remove_member` path (which requires a channel role) or if the + /// last-owner protection is dropped. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_remove_member_as_community_moderator_removes_ordinary_member() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + + let channel_owner_pk = random_pubkey(); + let target_pk = random_pubkey(); + let moderator_pk = random_pubkey(); // no channel membership + ensure_user(&pool, community, &channel_owner_pk) + .await + .expect("ensure channel owner"); + ensure_user(&pool, community, &target_pk) + .await + .expect("ensure target"); + ensure_user(&pool, community, &moderator_pk) + .await + .expect("ensure moderator"); + + let channel = create_test_channel( + &pool, + community_id, + "mod-kick-test", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &channel_owner_pk, + None, + ) + .await + .expect("create channel"); + + add_member( + &pool, + community, + channel.id, + &target_pk, + MemberRole::Member, + Some(&channel_owner_pk), + ) + .await + .expect("add target as member"); + + // Moderator is NOT a channel member — the preauthorized path must still + // succeed. + remove_member_as_community_moderator( + &pool, + community, + channel.id, + &target_pk, + &moderator_pk, + ) + .await + .expect("moderator must be able to kick an ordinary member"); + + assert!( + !is_member(&pool, community, channel.id, &target_pk) + .await + .expect("is_member check"), + "target must no longer be a member" + ); + } + + /// The preauthorized mutation refuses to remove the last channel owner, + /// preserving the same guard the standard `remove_member` path enforces. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_remove_member_as_community_moderator_blocks_last_owner_removal() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + + let only_owner_pk = random_pubkey(); + let moderator_pk = random_pubkey(); + ensure_user(&pool, community, &only_owner_pk) + .await + .expect("ensure owner"); + ensure_user(&pool, community, &moderator_pk) + .await + .expect("ensure moderator"); + + let channel = create_test_channel( + &pool, + community_id, + "mod-last-owner-test", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &only_owner_pk, + None, + ) + .await + .expect("create channel"); + + let err = remove_member_as_community_moderator( + &pool, + community, + channel.id, + &only_owner_pk, + &moderator_pk, + ) + .await; + + assert!( + matches!(err, Err(DbError::AccessDenied(_))), + "must refuse to remove the last channel owner, got {err:?}" + ); + + // The owner must still be a member after the refused attempt. + assert!( + is_member(&pool, community, channel.id, &only_owner_pk) + .await + .expect("is_member check"), + "last owner must remain a member after a failed remove" + ); + } + + /// Attempting to remove a non-member returns `MemberNotFound`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_remove_member_as_community_moderator_returns_not_found_for_non_member() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + + let channel_owner_pk = random_pubkey(); + let non_member_pk = random_pubkey(); + let moderator_pk = random_pubkey(); + ensure_user(&pool, community, &channel_owner_pk) + .await + .expect("ensure channel owner"); + ensure_user(&pool, community, &non_member_pk) + .await + .expect("ensure target"); + ensure_user(&pool, community, &moderator_pk) + .await + .expect("ensure moderator"); + + let channel = create_test_channel( + &pool, + community_id, + "mod-not-found-test", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &channel_owner_pk, + None, + ) + .await + .expect("create channel"); + + let err = remove_member_as_community_moderator( + &pool, + community, + channel.id, + &non_member_pk, + &moderator_pk, + ) + .await; + + assert!( + matches!(err, Err(DbError::MemberNotFound(_))), + "must return MemberNotFound for a non-member target, got {err:?}" + ); + } } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b26876747..527b3c4bf6 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2238,6 +2238,29 @@ impl Db { channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await } + /// Removes a channel member as an authorized community moderator, bypassing + /// the channel-role requirement of [`Self::remove_member`]. + /// + /// Must only be called after `authorize_moderation_action(..., Kick)` has + /// succeeded — the function name is the contract; no `skip_auth` flag. + /// See [`channel::remove_member_as_community_moderator`]. + pub async fn remove_member_as_community_moderator( + &self, + community_id: CommunityId, + channel_id: Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result<()> { + channel::remove_member_as_community_moderator( + &self.pool, + community_id, + channel_id, + target_pubkey, + actor_pubkey, + ) + .await + } + /// Returns `true` if the pubkey is an active member. pub async fn is_member( &self, @@ -4106,6 +4129,20 @@ impl Db { .await } + /// Atomically removes a relay member from `community` only if their role + /// is `'member'` or `'moderator'`. + /// + /// Used by the admin 9031 path so an admin cannot remove a fellow admin + /// or owner. Returns `RoleMismatch` when the target holds an elevated + /// role. See [`relay_members::remove_relay_member_if_non_admin`]. + pub async fn remove_relay_member_if_non_admin( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + relay_members::remove_relay_member_if_non_admin(&self.pool, community, pubkey).await + } + /// Updates the role of an existing relay member in `community`. Returns `true` if updated. pub async fn update_relay_member_role( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 65ca156721..a847d8a752 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 27); + assert_eq!(migrations.len(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -940,6 +940,30 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + + // Relay-member moderator role (0028): extends the relay_members.role + // CHECK to include 'moderator'. The constraint is dropped and + // recreated by name (relay_members_role_check). Schema snapshot must + // reflect the new value set; constraint in the migration must be safe + // to run on any brownfield that already has the three-value CHECK. + assert_eq!(migrations[27].version, 28); + let moderator_role = migrations[27].sql.as_str(); + assert!( + moderator_role.contains("relay_members_role_check"), + "0028 must reference the constraint by its generated name", + ); + assert!( + moderator_role.contains("'moderator'"), + "0028 must include 'moderator' in the new CHECK", + ); + assert!( + moderator_role.contains("DROP CONSTRAINT IF EXISTS"), + "0028 must use DROP CONSTRAINT IF EXISTS for idempotency", + ); + assert!( + desired_schema.contains("'moderator'"), + "desired-state schema must include 'moderator' in relay_members.role CHECK", + ); } #[test] @@ -1182,7 +1206,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(27)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(28)); } #[tokio::test] diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 402229cdec..996e4fdf53 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -17,7 +17,7 @@ use crate::CommunityId; pub struct RelayMember { /// 64-char lowercase hex pubkey. pub pubkey: String, - /// Role: `"owner"`, `"admin"`, or `"member"`. + /// Role: `"owner"`, `"admin"`, `"moderator"`, or `"member"`. pub role: String, /// Hex pubkey of who added this member, or `None` for bootstrap entries. pub added_by: Option, @@ -309,6 +309,56 @@ pub async fn remove_relay_member_if_role( } } +/// Atomically removes a relay member from `community` only if their role is +/// `'member'` or `'moderator'`. Used by the admin removal path so that an +/// admin cannot remove a fellow admin; eliminates the TOCTOU race where the +/// target could be promoted between a prior role read and the DELETE. +/// +/// Returns [`RemoveResult::RoleMismatch`] when the target exists but holds a +/// role that is not in the allowed set (e.g. `admin`). +pub async fn remove_relay_member_if_non_admin( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result { + // Atomic single-statement delete: role IN ('member', 'moderator'). + // Using `= ANY(ARRAY[…])` keeps the parameter count low and avoids + // dynamic SQL; the constant set is determined by the function contract. + let result = sqlx::query( + "DELETE FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 \ + AND role = ANY(ARRAY['member','moderator'])", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + + // Zero rows deleted: distinguish not-found from an admin/owner target. + let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + match row { + None => Ok(RemoveResult::NotFound), + Some(r) => { + let role: String = r.try_get("role")?; + if role == "owner" { + Ok(RemoveResult::IsOwner) + } else { + // admin (or any future elevated role) — caller lacks authority. + Ok(RemoveResult::RoleMismatch) + } + } + } +} + /// Updates the role of an existing relay member in `community`. Returns `true` /// if updated. pub async fn update_relay_member_role( diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 3d4b7f4a0a..fe3582085c 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -64,6 +64,8 @@ pub enum ModerationAuthority { CommunityOwner, /// Actor is community `admin` in `relay_members`. CommunityAdmin, + /// Actor is community `moderator` in `relay_members`. + CommunityModerator, /// Actor is channel owner/admin of the target's channel. ChannelRole, } @@ -100,8 +102,8 @@ pub async fn authorize_moderation_action( .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. + // an admin actioning a pubkey with ban/timeout — and for the moderator guard + // rail — a moderator cannot Kick/Timeout a community owner or admin. let target_role = match (actor_role.as_deref(), action, target) { (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { match target { @@ -113,13 +115,23 @@ pub async fn authorize_moderation_action( _ => None, } } + (Some("moderator"), ModerationAction::Kick | 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, + (Some("owner") | Some("admin") | Some("moderator"), _, _) => None, (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { state .db @@ -168,8 +180,26 @@ fn decide_authority( } Ok(ModerationAuthority::CommunityAdmin) } - // Not a community owner/admin: channel owner/admin keep channel-local - // authority for DeleteMessage/Kick only. + // Moderator: ViewQueue, ResolveReport, DeleteMessage, Kick, Timeout, + // Untimeout — community-wide. Ban/Unban stay admin+ (only an admin or + // owner may lift or apply a ban). Guard rail: a moderator cannot Kick + // or Timeout the community owner or a fellow admin; only the owner can + // action an admin. Target-role reads for the guard are loaded by the + // caller in `authorize_moderation_action`; arriving here with a + // `Some("owner") | Some("admin")` target means the caller resolved it. + Some("moderator") => { + if matches!(action, ModerationAction::Ban | ModerationAction::Unban) { + anyhow::bail!("a moderator cannot ban or unban community members"); + } + if matches!(action, ModerationAction::Kick | ModerationAction::Timeout) + && matches!(target_role, Some("owner") | Some("admin")) + { + anyhow::bail!("a moderator cannot kick or time out a community owner or admin"); + } + Ok(ModerationAuthority::CommunityModerator) + } + // Not a community owner/admin/moderator: channel owner/admin keep + // channel-local authority for DeleteMessage/Kick only. _ => match (action, channel_role) { ( ModerationAction::DeleteMessage | ModerationAction::Kick, @@ -332,4 +362,141 @@ mod tests { ); } } + + // ── moderator tests ─────────────────────────────────────────────────────── + + /// Moderator can take every action except Ban/Unban, against non-privileged + /// targets (member, unknown, or no target). + #[test] + fn moderator_authorized_for_non_ban_actions_against_member_and_non_member() { + const MODERATOR_ALLOWED: [ModerationAction; 6] = [ + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::Timeout, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ]; + for action in MODERATOR_ALLOWED { + assert_eq!( + ok(decide_authority( + Some("moderator"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} against a member" + ); + assert_eq!( + ok(decide_authority(Some("moderator"), None, None, action)), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} against a non-member" + ); + } + } + + /// Moderator cannot ban or unban — those stay admin+. + #[test] + fn moderator_cannot_ban_or_unban() { + for action in [ModerationAction::Ban, ModerationAction::Unban] { + for target in [Some("member"), Some("moderator"), None] { + assert!( + decide_authority(Some("moderator"), target, None, action).is_err(), + "moderator must not {action:?} (target={target:?})" + ); + } + } + } + + /// Moderator guard rail: cannot Kick or Timeout the community owner or an admin. + #[test] + fn moderator_cannot_kick_or_timeout_owner_or_admin() { + for target in ["owner", "admin"] { + for action in [ModerationAction::Kick, ModerationAction::Timeout] { + assert!( + decide_authority(Some("moderator"), Some(target), None, action).is_err(), + "moderator must not {action:?} a community {target}" + ); + } + } + } + + /// Moderator guard rail is scoped to Kick/Timeout — not to Untimeout or + /// other actions. Reversals on admin targets are always allowed. + #[test] + fn moderator_guard_rail_scoped_to_kick_and_timeout() { + for action in [ + ModerationAction::Untimeout, + ModerationAction::DeleteMessage, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + // Even against an admin target: the guard rail only protects + // applying punitive actions, not reversals or reads. + assert_eq!( + ok(decide_authority( + Some("moderator"), + Some("admin"), + None, + action + )), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} even against an admin target" + ); + } + } + + /// Moderator can Kick/Timeout plain members and non-members — the guard + /// rail fires only on owner/admin targets. + #[test] + fn moderator_can_kick_and_timeout_plain_targets() { + for action in [ModerationAction::Kick, ModerationAction::Timeout] { + assert_eq!( + ok(decide_authority( + Some("moderator"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} against a member" + ); + assert_eq!( + ok(decide_authority(Some("moderator"), None, None, action)), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} against a non-member" + ); + // Moderator targets are peers — no guard rail there. + assert_eq!( + ok(decide_authority( + Some("moderator"), + Some("moderator"), + None, + action + )), + ModerationAuthority::CommunityModerator, + "moderator must be authorized for {action:?} against a fellow moderator" + ); + } + } + + /// A community moderator has no channel role loaded (the caller skips the + /// channel_role DB read for community-level actors), so the channel_role + /// fallthrough never fires for them. + #[test] + fn moderator_does_not_use_channel_role_path() { + // Even if a channel_role value somehow arrived (misconfigured caller), + // the moderator arm fires before the channel-role fallthrough. + assert_eq!( + ok(decide_authority( + Some("moderator"), + None, + Some("owner"), + ModerationAction::Kick + )), + ModerationAuthority::CommunityModerator, + "moderator arm must win over the channel-role fallthrough" + ); + } } diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index c769ac3cb9..b27119bc69 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -68,6 +68,7 @@ use nostr::Event; use tracing::info; use uuid::Uuid; +use crate::handlers::ingest::effective_message_author; use crate::handlers::moderation_authz::{ authorize_moderation_action, ModerationAction, ModerationTarget, }; @@ -363,6 +364,18 @@ async fn handle_untimeout( // ── 9044: resolve report ───────────────────────────────────────────────────── +/// The resolved report-action value: built once from the stored report and the +/// (tombstoned-or-live) target event, then used for authorization and close. +struct ResolvedReportAction { + /// The capability required to execute this action label. + required_capability: ModerationAction, + /// The effective author of the target (event reports) or the report target + /// itself (pubkey reports). Feeds the target-role guard rail check. + target_author: Vec, + /// Channel id from the report row, required for delete/kick actions. + channel_id: Option, +} + async fn handle_resolve( tenant: &TenantContext, state: &Arc, @@ -396,17 +409,6 @@ async fn handle_resolve( )); } - 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 @@ -429,6 +431,32 @@ async fn handle_resolve( )); } + // Build the resolved report-action value once from the stored report and the + // (tombstoned-or-live) event. This is the single source of truth for target + // shape, effective author, channel, and required capability. + let resolved = build_resolved_action(tenant, state, &action, &report).await?; + + // Authorize using the action-specific capability and target author. + // Log the authorization decision (attempt) now; the success log fires after + // mutation commit below. + let authority = authorize_moderation_action( + tenant, + state, + actor, + resolved.channel_id, + ModerationTarget::Pubkey(&resolved.target_author), + resolved.required_capability, + ) + .await + .map_err(authz_denial)?; + tracing::debug!( + action = %action, + capability = ?resolved.required_capability, + authority = ?authority, + actor = %hex::encode(actor), + "9044 authorization attempt" + ); + // 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 { @@ -456,7 +484,7 @@ async fn handle_resolve( ) .await?; - let resolved = state + let resolved_db = state .db .resolve_moderation_report( tenant.community(), @@ -467,12 +495,21 @@ async fn handle_resolve( ) .await .map_err(|e| error(format!("database error: {e}")))?; - if !resolved { + if !resolved_db { return Err(invalid( "report is not open (already resolved or dismissed)", )); } + // Log after mutation commit (per the plan's authority-logging disposition). + info!( + report_id = %report.id, + status = %status, + action = %action, + authority = ?authority, + "report resolved" + ); + // 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(), @@ -493,10 +530,108 @@ async fn handle_resolve( info!(error = %e, "report-resolution notice DM delivery failed (report still resolved)"); } - info!(report_id = %report.id, status = %status, action = %action, "report resolved"); Ok(()) } +/// Build the [`ResolvedReportAction`] from the stored report and the action +/// label. This is the normalization matrix from plan v3.2: +/// +/// - `delete` / `kick`: require an event target **with a channel_id**; the +/// target event is resolved via `get_event_by_id_including_deleted` (the +/// queue enforce-first, so the 9005 target is tombstoned by 9044 time — a +/// live-only read would strand the report open); real author via +/// `effective_message_author`. +/// - `ban` / `timeout`: require a pubkey target or an event target whose +/// effective author resolves; blob targets rejected. +/// - `dismiss` / `escalate`: decision-only, target-agnostic; target_author is +/// set to the report's own reporter pubkey as a placeholder (it is never used +/// in the capability check, which only requires `ResolveReport`). +async fn build_resolved_action( + tenant: &TenantContext, + state: &Arc, + action: &str, + report: &buzz_db::moderation::ReportRecord, +) -> Result { + use buzz_db::moderation::ReportTarget; + + let required_capability = match action { + "delete" => ModerationAction::DeleteMessage, + "kick" => ModerationAction::Kick, + "ban" => ModerationAction::Ban, + "timeout" => ModerationAction::Timeout, + "dismiss" | "escalate" => ModerationAction::ResolveReport, + _ => return Err(invalid(format!("unknown action: {action}"))), + }; + + match action { + "delete" | "kick" => { + // Require an event target with a channel. + let event_id = match &report.target { + ReportTarget::Event(id) => id.clone(), + _ => { + return Err(invalid(format!( + "action `{action}` requires an event report target" + ))) + } + }; + let channel_id = report.channel_id.ok_or_else(|| { + invalid(format!( + "action `{action}` requires a report with a channel (event must belong to a channel)" + )) + })?; + // Use the including-deleted variant: the queue may have already + // executed a 9005 on this event before the 9044 arrived. + let stored = state + .db + .get_event_by_id_including_deleted(tenant.community(), &event_id) + .await + .map_err(|e| error(format!("database error looking up target event: {e}")))? + .ok_or_else(|| invalid("target event not found in this community"))?; + let target_author = + effective_message_author(&stored.event, &state.relay_keypair.public_key()); + Ok(ResolvedReportAction { + required_capability, + target_author, + channel_id: Some(channel_id), + }) + } + "ban" | "timeout" => { + // Require a pubkey target, or an event target whose effective author + // resolves. Blob targets are rejected. + let target_author = match &report.target { + ReportTarget::Pubkey(pk) => pk.clone(), + ReportTarget::Event(event_id) => { + let stored = state + .db + .get_event_by_id_including_deleted(tenant.community(), event_id) + .await + .map_err(|e| error(format!("database error looking up target event: {e}")))? + .ok_or_else(|| { + invalid("target event not found — cannot resolve effective author") + })?; + effective_message_author(&stored.event, &state.relay_keypair.public_key()) + } + ReportTarget::Blob(_) => { + return Err(invalid(format!( + "action `{action}` does not apply to blob targets" + ))) + } + }; + Ok(ResolvedReportAction { + required_capability, + target_author, + channel_id: report.channel_id, + }) + } + // dismiss / escalate are decision-only and target-agnostic. + _ => Ok(ResolvedReportAction { + required_capability, + target_author: report.reporter_pubkey.clone(), + channel_id: None, + }), + } +} + // ── shared helpers ──────────────────────────────────────────────────────────── fn resolution_audit_action(action: &str) -> &'static str { diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516..f0ce38f70f 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -326,7 +326,7 @@ async fn execute_relay_admin_command( if role == "admin" && sender_role != "owner" { return Err("actor not authorized: only owner can grant admin role".to_string()); } - if role != "admin" && role != "member" { + if role != "admin" && role != "member" && role != "moderator" { return Err(format!("invalid role: {role}")); } @@ -372,14 +372,14 @@ async fn execute_relay_admin_command( } // Dispatch removal by sender role: - // - Admins: atomic conditional delete, only removes 'member' targets. - // This eliminates the TOCTOU race where the target could be promoted - // between a prior role read and the delete. - // - Owners: can remove admins and members, not other owners. + // - Admins: atomic conditional delete that removes 'member' or + // 'moderator' targets — covers the new moderator tier without a + // TOCTOU race. Uses role = ANY($3) to match both atomically. + // - Owners: can remove admins, moderators, and members, not other owners. let remove_result = if sender_role == "admin" { state .db - .remove_relay_member_if_role(tenant.community(), &target_hex, "member") + .remove_relay_member_if_non_admin(tenant.community(), &target_hex) .await .map_err(|e| format!("database error: {e}"))? } else { @@ -400,7 +400,10 @@ async fn execute_relay_admin_command( return Err(format!("member not found: {target_hex}")); } RemoveResult::RoleMismatch => { - return Err("actor not authorized: admins can only remove members".to_string()); + return Err( + "actor not authorized: admins can only remove members or moderators" + .to_string(), + ); } } @@ -439,7 +442,7 @@ async fn execute_relay_admin_command( if new_role == "owner" { return Err("cannot set role to owner".to_string()); } - if new_role != "admin" && new_role != "member" { + if new_role != "admin" && new_role != "member" && new_role != "moderator" { return Err(format!("invalid role: {new_role}")); } @@ -896,4 +899,104 @@ mod tests { Some("https://example.com/closed.png") ); } + + // ── moderator role integration tests ───────────────────────────────────── + + /// Admin may remove a moderator (atomic ANY-predicate path) but not an + /// owner or a fellow admin. Tests the multi-role predicate from the plan's + /// MINOR finding. + /// + /// Discriminating: fails if the atomic predicate is split into two + /// sequential removes or if the admin guard rail is omitted. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_can_remove_member_and_moderator_but_not_owner_or_admin() { + let host = format!("moderator-remove-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, true).await; + let owner_keys = Keys::generate(); + let admin_keys = Keys::generate(); + let mod_keys = Keys::generate(); + let member_keys = Keys::generate(); + + let community = tenant.community(); + + state + .db + .add_relay_member(community, &owner_keys.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + state + .db + .add_relay_member(community, &admin_keys.public_key().to_hex(), "admin", None) + .await + .expect("seed admin"); + state + .db + .add_relay_member( + community, + &mod_keys.public_key().to_hex(), + "moderator", + None, + ) + .await + .expect("seed moderator"); + state + .db + .add_relay_member( + community, + &member_keys.public_key().to_hex(), + "member", + None, + ) + .await + .expect("seed member"); + + // Admin can remove a moderator (atomic ANY predicate covers both). + let remove_mod = state + .db + .remove_relay_member_if_non_admin(community, &mod_keys.public_key().to_hex()) + .await + .expect("should not error"); + assert_eq!( + remove_mod, + buzz_db::relay_members::RemoveResult::Removed, + "admin must be able to remove a moderator" + ); + + // Admin can remove a plain member. + let remove_member = state + .db + .remove_relay_member_if_non_admin(community, &member_keys.public_key().to_hex()) + .await + .expect("should not error"); + assert_eq!( + remove_member, + buzz_db::relay_members::RemoveResult::Removed, + "admin must be able to remove a plain member" + ); + + // Admin cannot remove another admin — role mismatch. + let remove_admin = state + .db + .remove_relay_member_if_non_admin(community, &admin_keys.public_key().to_hex()) + .await + .expect("should not error"); + assert_eq!( + remove_admin, + buzz_db::relay_members::RemoveResult::RoleMismatch, + "admin must NOT be able to remove a fellow admin" + ); + + // Admin cannot remove the owner — owner is separately protected. + let remove_owner = state + .db + .remove_relay_member_if_non_admin(community, &owner_keys.public_key().to_hex()) + .await + .expect("should not error"); + assert_eq!( + remove_owner, + buzz_db::relay_members::RemoveResult::IsOwner, + "admin must NOT be able to remove the owner" + ); + } } diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..8d314bd69c 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -17,6 +17,9 @@ use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; use super::event::dispatch_persistent_event; +use crate::handlers::moderation_authz::{ + authorize_moderation_action, ModerationAction, ModerationTarget, +}; use crate::protocol::RelayMessage; use crate::state::AppState; use buzz_core::tenant::TenantContext; @@ -480,13 +483,52 @@ pub async fn validate_admin_event( { Ok(()) } else { - Err(anyhow::anyhow!("actor not authorized")) + // Additive relay-role path: a community moderator + // (or community owner/admin lacking a channel role) + // may kick a member via 9001. `authorize_moderation_action` + // resolves the target's relay role for the guard-rail + // check inside the call. + authorize_moderation_action( + tenant, + state, + &actor_bytes, + Some(channel_id), + ModerationTarget::Pubkey(&target_pubkey), + ModerationAction::Kick, + ) + .await + .map(|_| ()) + .map_err(|_| anyhow::anyhow!("actor not authorized")) } } - // Non-members fall here. We intentionally do NOT check - // is_agent_owner for non-members — you must be in the channel - // to remove anyone, even your own bot. - _ => Err(anyhow::anyhow!("actor not authorized")), + // Non-members: check relay-role authority before failing. + // A community moderator who holds no channel membership can + // still kick via the relay-role seam. + _ => { + if state + .db + .is_agent_owner(tenant.community(), &target_pubkey, &actor_bytes) + .await? + { + // NOTE: agent-owner callers are also channel members + // (agents join before humans chat with them), but + // handle the edge case uniformly — agent owners who + // lost channel membership can still remove their bot. + return Ok(()); + } + // Relay-level authority path. + authorize_moderation_action( + tenant, + state, + &actor_bytes, + Some(channel_id), + ModerationTarget::Pubkey(&target_pubkey), + ModerationAction::Kick, + ) + .await + .map(|_| ()) + .map_err(|_| anyhow::anyhow!("actor not authorized")) + } } } } @@ -709,9 +751,31 @@ pub async fn validate_admin_event( { Ok(()) } else { - Err(anyhow::anyhow!( - "must be event author or channel owner/admin" - )) + // Additive relay-role path: community moderator (and + // community owner/admin who lack a channel role) can delete + // any message via 9005. The target author's role is loaded + // inside `authorize_moderation_action` for the guard-rail + // check; the author is already resolved above. + authorize_moderation_action( + tenant, + state, + &actor_bytes, + Some(channel_id), + ModerationTarget::Pubkey(&author), + ModerationAction::DeleteMessage, + ) + .await + .map_err(|_| anyhow::anyhow!("must be event author or channel owner/admin")) + .map(|_authority| { + // authorization succeeded; log here rather than + // persisting (per the plan's audit-attribution decision) + tracing::debug!( + actor = %hex::encode(&actor_bytes), + target_author = %hex::encode(&author), + channel = %channel_id, + "9005 authorized via relay-role moderation seam" + ); + }) } } } @@ -1381,10 +1445,42 @@ async fn handle_remove_user( } } - state + // Route through the preauthorized mutation when the actor is not an active + // channel member (i.e., they arrived via the relay-role moderation seam — + // community moderator/owner/admin without a channel role). The standard + // `remove_member` path re-checks the channel role inside its transaction; + // a relay-level moderator who is not a channel member would fail that check. + let actor_has_channel_role = state .db - .remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes) - .await?; + .get_member_role(tenant.community(), channel_id, &actor_bytes) + .await + .map(|r| r.is_some()) + .unwrap_or(false); + + if actor_has_channel_role || target_pubkey == actor_bytes { + state + .db + .remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes) + .await?; + } else { + // Relay-role path: the validator already ran `authorize_moderation_action` + // and passed — call the preauthorized mutation (no channel-role re-check). + tracing::debug!( + actor = %hex::encode(&actor_bytes), + target = %hex::encode(&target_pubkey), + channel = %channel_id, + "9001 kick via relay-role moderation seam" + ); + state + .db + .remove_member_as_community_moderator( + tenant.community(), + channel_id, + &target_pubkey, + &actor_bytes, + ) + .await?; + } state.invalidate_membership(tenant, channel_id, &target_pubkey); evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..bfabed2514 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1708,7 +1708,7 @@ async fn emit_db_usage_metrics( // Zero-fill across all (community, role) pairs; relay_members.role is a // CHECK constraint over {'owner', 'admin', 'member'}. { - const RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; + const RELAY_ROLES: &[&str] = &["owner", "admin", "moderator", "member"]; let rows: HashMap<(Uuid, &str), i64> = relay_member_rows .into_iter() .filter_map(|r| { diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5..b0a41416ff 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -87,10 +87,10 @@ case "${1:-help}" in backup_hint ;; add-member) - docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member [--role member|admin]}" "${@:3}" + docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member [--role member|moderator|admin]}" "${@:3}" ;; remove-member) - docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member [--role member|admin]}" "${@:3}" + docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member [--role member|moderator|admin]}" "${@:3}" ;; list-members) docker compose exec relay /usr/local/bin/buzz-admin list-members @@ -110,9 +110,9 @@ Commands: config Render merged compose config backup-hint Print the production backup checklist - add-member [--role member|admin] + add-member [--role member|moderator|admin] Add a relay member (default role: member) - remove-member [--role member|admin] + remove-member [--role member|moderator|admin] Remove a relay member list-members List all relay members diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..869cfc25ba 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,6 +35,7 @@ mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; mod messages; +mod moderator; mod notifications; mod observer_archive; mod os_idle; @@ -91,6 +92,7 @@ pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; +pub use moderator::*; pub use notifications::*; pub use observer_archive::*; pub use os_idle::*; diff --git a/desktop/src-tauri/src/commands/moderator.rs b/desktop/src-tauri/src/commands/moderator.rs new file mode 100644 index 0000000000..3fdfb70bc0 --- /dev/null +++ b/desktop/src-tauri/src/commands/moderator.rs @@ -0,0 +1,27 @@ +use nostr::{EventBuilder, EventId, Kind, Tag}; +use tauri::State; + +use crate::{app_state::AppState, relay::submit_event}; + +/// Delete a message as a relay-level moderator, owner, or admin (kind 9005). +/// +/// Unlike `delete_message` (kind 5, author-only), this sends a Buzz-native +/// moderator delete event validated by the relay against the actor's relay +/// role via `authorize_moderation_action`. +#[tauri::command] +pub async fn moderator_delete_message( + channel_id: String, + event_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let channel_uuid = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let tags = vec![ + Tag::parse(vec!["h", &channel_uuid.to_string()]).map_err(|e| format!("tag error: {e}"))?, + Tag::parse(vec!["e", &target_eid.to_hex()]).map_err(|e| format!("tag error: {e}"))?, + ]; + let builder = EventBuilder::new(Kind::Custom(9005), "").tags(tags); + submit_event(builder, &state).await?; + Ok(()) +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..98cbe2e4af 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -585,7 +585,7 @@ pub fn build_note( // ── Relay admin (NIP-43) ──────────────────────────────────────────────────── /// Allowed relay member roles for NIP-43 admin commands. -const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; +const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "moderator", "member"]; fn check_relay_role(role: &str) -> Result<(), String> { if !VALID_RELAY_ROLES.contains(&role) { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d59936946f..947b49de30 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -715,6 +715,7 @@ pub fn run() { get_channel_messages_before, edit_message, delete_message, + moderator_delete_message, add_reaction, remove_reaction, get_event, diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index d861fae802..2d2c0c0f7a 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -1,4 +1,10 @@ -import { Crown, MoreHorizontal, Search, Shield } from "lucide-react"; +import { + Crown, + MoreHorizontal, + Search, + Shield, + ShieldHalf, +} from "lucide-react"; import { nip19 } from "nostr-tools"; import * as React from "react"; import { toast } from "sonner"; @@ -103,10 +109,28 @@ function RelayMemberRow({ const canRemove = !isSelf && member.role !== "owner" && - (currentRole === "owner" || member.role === "member"); - const canPromote = currentRole === "owner" && member.role === "member"; - const canDemote = currentRole === "owner" && member.role === "admin"; - const hasActions = canRemove || canPromote || canDemote; + (currentRole === "owner" || + (currentRole === "admin" && + (member.role === "member" || member.role === "moderator"))); + // Only the owner can change roles above member. + const canMakeAdmin = currentRole === "owner" && member.role === "member"; + const canMakeModerator = currentRole === "owner" && member.role === "member"; + const canPromoteModeratorToAdmin = + currentRole === "owner" && member.role === "moderator"; + const canDemoteAdminToModerator = + currentRole === "owner" && member.role === "admin"; + const canDemoteModeratorToMember = + currentRole === "owner" && member.role === "moderator"; + const canDemoteAdminToMember = + currentRole === "owner" && member.role === "admin"; + const hasActions = + canRemove || + canMakeAdmin || + canMakeModerator || + canPromoteModeratorToAdmin || + canDemoteAdminToModerator || + canDemoteModeratorToMember || + canDemoteAdminToMember; const displayName = formatDisplayName(member, profile?.displayName); async function mutateWithToast( @@ -153,6 +177,9 @@ function RelayMemberRow({ {member.role === "admin" ? ( ) : null} + {member.role === "moderator" ? ( + + ) : null}
{member.role} @@ -185,7 +212,39 @@ function RelayMemberRow({ - {canPromote ? ( + {canMakeAdmin ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "admin", + }), + "Made community admin", + ) + } + > + Make admin + + ) : null} + {canMakeModerator ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "moderator", + }), + "Made community moderator", + ) + } + > + Make moderator + + ) : null} + {canPromoteModeratorToAdmin ? ( void mutateWithToast( @@ -201,7 +260,39 @@ function RelayMemberRow({ Make admin ) : null} - {canDemote ? ( + {canDemoteAdminToModerator ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "moderator", + }), + "Made community moderator", + ) + } + > + Make moderator + + ) : null} + {canDemoteAdminToMember ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "member", + }), + "Made community member", + ) + } + > + Make member + + ) : null} + {canDemoteModeratorToMember ? ( void mutateWithToast( @@ -217,7 +308,13 @@ function RelayMemberRow({ Make member ) : null} - {canRemove && (canPromote || canDemote) ? ( + {canRemove && + (canMakeAdmin || + canMakeModerator || + canPromoteModeratorToAdmin || + canDemoteAdminToModerator || + canDemoteAdminToMember || + canDemoteModeratorToMember) ? ( ) : null} {canRemove ? ( diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx index 283760fb02..907b4a32df 100644 --- a/desktop/src/features/messages/ui/TimelineMessageRow.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -4,8 +4,12 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import { moderationCapabilities } from "@/features/moderation/lib/capabilities"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { moderatorDeleteMessage } from "@/shared/api/moderator"; import { cn } from "@/shared/lib/cn"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; @@ -131,6 +135,25 @@ export function MessageRowItem({ const canDelete = canManage && onDelete ? onDelete : undefined; const canEdit = canManage && onEdit ? onEdit : undefined; + // Moderator delete affordance: relay owners, admins, and moderators may + // delete messages they do not own, using kind 9005 (moderator delete). + // Self-deletions still flow through the normal `canDelete` path (kind 5). + const relayMembershipQuery = useMyRelayMembershipQuery(); + const relayRole = relayMembershipQuery.data?.role; + const caps = moderationCapabilities(relayRole); + const isOwnMessage = + currentPubkey != null && + message.pubkey != null && + normalizePubkey(message.pubkey) === normalizePubkey(currentPubkey); + const canModeratorDelete = + caps.canDelete && !isOwnMessage && channelId != null + ? (msg: TimelineMessage) => { + void moderatorDeleteMessage(channelId, msg.id).catch(() => { + // Failure is surfaced by the relay WebSocket rejection toast. + }); + } + : undefined; + if (summary && onOpenThread) { const isHighlighted = message.id === highlightedMessageId; return ( @@ -158,7 +181,7 @@ export function MessageRowItem({ playEntrance={playEntrance} onEntranceComplete={onEntranceComplete} message={message} - onDelete={canDelete} + onDelete={canDelete ?? canModeratorDelete} onEdit={canEdit} onFollowThread={ followThreadById ? () => followThreadById(message.id) : undefined @@ -211,7 +234,7 @@ export function MessageRowItem({ playEntrance={playEntrance} onEntranceComplete={onEntranceComplete} message={message} - onDelete={canDelete} + onDelete={canDelete ?? canModeratorDelete} onEdit={canEdit} onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} diff --git a/desktop/src/features/moderation/lib/capabilities.test.mjs b/desktop/src/features/moderation/lib/capabilities.test.mjs new file mode 100644 index 0000000000..5e305ce194 --- /dev/null +++ b/desktop/src/features/moderation/lib/capabilities.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { moderationCapabilities } from "./capabilities.ts"; + +// ── owner / admin ───────────────────────────────────────────────────────────── + +test("moderationCapabilities: owner gets all capabilities", () => { + const caps = moderationCapabilities("owner"); + assert.strictEqual(caps.canDelete, true); + assert.strictEqual(caps.canKick, true); + assert.strictEqual(caps.canTimeout, true); + assert.strictEqual(caps.canUntimeout, true); + assert.strictEqual(caps.canBan, true); + assert.strictEqual(caps.canUnban, true); + assert.strictEqual(caps.canViewQueue, true); + assert.strictEqual(caps.canResolve, true); +}); + +test("moderationCapabilities: admin gets all capabilities", () => { + const caps = moderationCapabilities("admin"); + assert.strictEqual(caps.canDelete, true); + assert.strictEqual(caps.canKick, true); + assert.strictEqual(caps.canTimeout, true); + assert.strictEqual(caps.canUntimeout, true); + assert.strictEqual(caps.canBan, true); + assert.strictEqual(caps.canUnban, true); + assert.strictEqual(caps.canViewQueue, true); + assert.strictEqual(caps.canResolve, true); +}); + +// ── moderator ───────────────────────────────────────────────────────────────── + +test("moderationCapabilities: moderator gets delete/kick/timeout/untimeout/queue/resolve", () => { + const caps = moderationCapabilities("moderator"); + assert.strictEqual(caps.canDelete, true); + assert.strictEqual(caps.canKick, true); + assert.strictEqual(caps.canTimeout, true); + assert.strictEqual(caps.canUntimeout, true); + assert.strictEqual(caps.canViewQueue, true); + assert.strictEqual(caps.canResolve, true); +}); + +test("moderationCapabilities: moderator does NOT get ban or unban", () => { + const caps = moderationCapabilities("moderator"); + assert.strictEqual(caps.canBan, false); + assert.strictEqual(caps.canUnban, false); +}); + +// ── member / null / undefined ───────────────────────────────────────────────── + +test("moderationCapabilities: member gets no capabilities", () => { + const caps = moderationCapabilities("member"); + assert.strictEqual(caps.canDelete, false); + assert.strictEqual(caps.canKick, false); + assert.strictEqual(caps.canTimeout, false); + assert.strictEqual(caps.canUntimeout, false); + assert.strictEqual(caps.canBan, false); + assert.strictEqual(caps.canUnban, false); + assert.strictEqual(caps.canViewQueue, false); + assert.strictEqual(caps.canResolve, false); +}); + +test("moderationCapabilities: null gets no capabilities", () => { + const caps = moderationCapabilities(null); + assert.strictEqual(caps.canBan, false); + assert.strictEqual(caps.canDelete, false); + assert.strictEqual(caps.canViewQueue, false); +}); + +test("moderationCapabilities: undefined gets no capabilities", () => { + const caps = moderationCapabilities(undefined); + assert.strictEqual(caps.canBan, false); + assert.strictEqual(caps.canDelete, false); + assert.strictEqual(caps.canViewQueue, false); +}); + +// ── symmetry ────────────────────────────────────────────────────────────────── + +test("moderationCapabilities: owner and admin return the same set", () => { + const owner = moderationCapabilities("owner"); + const admin = moderationCapabilities("admin"); + assert.deepEqual(owner, admin); +}); + +test("moderationCapabilities: moderator is a strict subset of admin capabilities", () => { + const admin = moderationCapabilities("admin"); + const mod = moderationCapabilities("moderator"); + // Every capability a moderator holds, admin also holds. + for (const key of Object.keys(mod)) { + if (mod[key]) { + assert.strictEqual(admin[key], true, `admin must also have ${key}`); + } + } + // But admin has capabilities the moderator does not. + assert.strictEqual(admin.canBan, true); + assert.strictEqual(mod.canBan, false); +}); diff --git a/desktop/src/features/moderation/lib/capabilities.ts b/desktop/src/features/moderation/lib/capabilities.ts new file mode 100644 index 0000000000..c505a5404f --- /dev/null +++ b/desktop/src/features/moderation/lib/capabilities.ts @@ -0,0 +1,82 @@ +/** + * Relay-role–keyed moderation capability map. + * + * Single source of truth for which moderation actions each relay role may + * perform. Both `MessageModerationMenuItems` and the moderation queue consume + * this helper so the capability contract is never duplicated across surfaces. + * + * Capability grid (plan v3.2): + * - owner / admin: all actions (Delete, Kick, Timeout, Untimeout, Ban, Unban, + * Resolve, ViewQueue) + * - moderator: Delete, Kick, Timeout, Untimeout, Resolve, ViewQueue — NOT + * Ban/Unban; guard rails (cannot Kick/Timeout owner/admin) are enforced by + * the relay seam, not this map. + * - member / null: none + */ + +import type { RelayMemberRole } from "@/shared/api/types"; + +export type ModerationCapabilities = { + /** May delete messages (9005 path for non-owned messages). */ + canDelete: boolean; + /** May kick a user from a channel (9001). */ + canKick: boolean; + /** May time out a user (9042). */ + canTimeout: boolean; + /** May lift a timeout (9043). */ + canUntimeout: boolean; + /** May ban a user from the community (9040). Admin+ only. */ + canBan: boolean; + /** May lift a community ban (9041). Admin+ only. */ + canUnban: boolean; + /** May view the moderation queue. */ + canViewQueue: boolean; + /** May resolve/dismiss/escalate reports (9044). */ + canResolve: boolean; +}; + +const ADMIN_CAPABILITIES: ModerationCapabilities = { + canDelete: true, + canKick: true, + canTimeout: true, + canUntimeout: true, + canBan: true, + canUnban: true, + canViewQueue: true, + canResolve: true, +}; + +const MODERATOR_CAPABILITIES: ModerationCapabilities = { + canDelete: true, + canKick: true, + canTimeout: true, + canUntimeout: true, + canBan: false, + canUnban: false, + canViewQueue: true, + canResolve: true, +}; + +const NO_CAPABILITIES: ModerationCapabilities = { + canDelete: false, + canKick: false, + canTimeout: false, + canUntimeout: false, + canBan: false, + canUnban: false, + canViewQueue: false, + canResolve: false, +}; + +/** + * Returns the moderation capabilities for the given relay role. + * + * Pass `null` or `undefined` for unauthenticated/non-member callers. + */ +export function moderationCapabilities( + role: RelayMemberRole | null | undefined, +): ModerationCapabilities { + if (role === "owner" || role === "admin") return ADMIN_CAPABILITIES; + if (role === "moderator") return MODERATOR_CAPABILITIES; + return NO_CAPABILITIES; +} diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx index e61f63b687..5805609dec 100644 --- a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -10,6 +10,7 @@ import { useUnbanMemberMutation, useUntimeoutMemberMutation, } from "@/features/moderation/hooks"; +import { moderationCapabilities } from "@/features/moderation/lib/capabilities"; import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import type { TimelineMessage } from "@/features/messages/types"; import { isTimedOut } from "@/features/moderation/lib/restrictionState"; @@ -34,10 +35,11 @@ const TIMEOUT_PRESETS: { label: string; seconds: number }[] = [ * kick from the current channel. Self-contained (wires its own hooks, no props * threaded from the message row), mirroring ReportMessageDialog. * - * Renders nothing unless the viewer is a relay owner/admin, the message has a - * real signer, and that signer is not the viewer. Actions target - * `signerPubkey` — the raw signer, never a relay-delegated display author — per - * the security note on TimelineMessage. + * Renders nothing unless the viewer holds a moderating relay role (owner, admin, + * or moderator), the message has a real signer, and that signer is not the + * viewer. Ban/Unban are hidden for moderators (admin+ only per the capability + * grid). Actions target `signerPubkey` — the raw signer, never a + * relay-delegated display author — per the security note on TimelineMessage. */ export function MessageModerationMenuItems({ channelId, @@ -48,7 +50,7 @@ export function MessageModerationMenuItems({ }) { const relayMembershipQuery = useMyRelayMembershipQuery(); const relayRole = relayMembershipQuery.data?.role; - const canModerate = relayRole === "owner" || relayRole === "admin"; + const caps = moderationCapabilities(relayRole); const identityQuery = useIdentityQuery(); // Moderate the raw signer, never a relay-delegated display author. A message @@ -60,9 +62,13 @@ export function MessageModerationMenuItems({ normalizePubkey(targetPubkey) === normalizePubkey(identityQuery.data.pubkey); - const enabled = canModerate && targetPubkey != null && !isSelf; + // At minimum, the viewer must be able to perform at least one action. + const canActOnAny = + (caps.canTimeout || caps.canKick || caps.canBan) && + targetPubkey != null && + !isSelf; - const restrictionsQuery = useModerationRestrictionsQuery(enabled); + const restrictionsQuery = useModerationRestrictionsQuery(canActOnAny); const banMutation = useBanMemberMutation(); const unbanMutation = useUnbanMemberMutation(); const timeoutMutation = useTimeoutMemberMutation(); @@ -101,60 +107,62 @@ export function MessageModerationMenuItems({ [], ); - if (!enabled || targetPubkey == null) return null; + if (!canActOnAny || targetPubkey == null) return null; return ( <> - {timedOut ? ( - - void run( - () => untimeoutMutation.mutateAsync(targetPubkey), - "Timeout lifted", - ) - } - > - - Lift timeout - - ) : ( - - + void run( + () => untimeoutMutation.mutateAsync(targetPubkey), + "Timeout lifted", + ) + } > - - Time out author - - - {TIMEOUT_PRESETS.map((preset) => ( - - void run( - () => - timeoutMutation.mutateAsync({ - pubkey: targetPubkey, - expiresAt: - Math.floor(Date.now() / 1000) + preset.seconds, - }), - "Author timed out", - ) - } - > - {preset.label} - - ))} - - - )} + + Lift timeout + + ) : ( + + + + Time out author + + + {TIMEOUT_PRESETS.map((preset) => ( + + void run( + () => + timeoutMutation.mutateAsync({ + pubkey: targetPubkey, + expiresAt: + Math.floor(Date.now() / 1000) + preset.seconds, + }), + "Author timed out", + ) + } + > + {preset.label} + + ))} + + + ) + ) : null} - {channelId ? ( + {caps.canKick && channelId ? ( ) : null} - {isBanned ? ( - - void run( - () => unbanMutation.mutateAsync(targetPubkey), - "Ban lifted", - ) - } - > - - Lift ban - - ) : ( - - void run( - () => banMutation.mutateAsync({ pubkey: targetPubkey }), - "Author banned", - ) - } - > - - Ban author from community - - )} + {caps.canBan ? ( + isBanned ? ( + + void run( + () => unbanMutation.mutateAsync(targetPubkey), + "Ban lifted", + ) + } + > + + Lift ban + + ) : ( + + void run( + () => banMutation.mutateAsync({ pubkey: targetPubkey }), + "Author banned", + ) + } + > + + Ban author from community + + ) + ) : null} ); } diff --git a/desktop/src/features/settings/lib/moderationQueue.test.mjs b/desktop/src/features/settings/lib/moderationQueue.test.mjs index 85ae8a2b91..c208eb288e 100644 --- a/desktop/src/features/settings/lib/moderationQueue.test.mjs +++ b/desktop/src/features/settings/lib/moderationQueue.test.mjs @@ -248,6 +248,43 @@ test("resolvableActions: timeout is never offered from one-click yet", () => { } }); +// ── canBan=false (moderator actor) ──────────────────────────────────────────── + +test("resolvableActions: canBan=false hides ban from event target with channel", () => { + const actions = resolvableActions("event", true, false); + assert.ok(!actions.includes("ban"), "ban must not appear for moderators"); + // delete and kick are still available (moderator can use them) + assert.ok(actions.includes("delete")); + assert.ok(actions.includes("kick")); + assert.ok(actions.includes("escalate")); + assert.ok(actions.includes("dismiss")); +}); + +test("resolvableActions: canBan=false hides ban from event target without channel", () => { + const actions = resolvableActions("event", false, false); + assert.ok(!actions.includes("ban")); + assert.ok(actions.includes("escalate")); + assert.ok(actions.includes("dismiss")); +}); + +test("resolvableActions: canBan=false hides ban from pubkey target", () => { + const actions = resolvableActions("pubkey", false, false); + assert.ok(!actions.includes("ban")); + // pubkey targets have no delete/kick regardless + assert.ok(!actions.includes("delete")); + assert.ok(!actions.includes("kick")); + assert.ok(actions.includes("escalate")); + assert.ok(actions.includes("dismiss")); +}); + +test("resolvableActions: canBan=true (default) still includes ban for event target with channel", () => { + // Ensure the default backward-compat value is preserved for admins. + const withDefault = resolvableActions("event", true); + const withExplicit = resolvableActions("event", true, true); + assert.deepEqual(withDefault, withExplicit); + assert.ok(withDefault.includes("ban")); +}); + test("buildModerationQueue carries channelId from the report onto the group", () => { const t = "d".repeat(64); const [group] = buildModerationQueue([ diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts index 9ef377e457..2f692763a1 100644 --- a/desktop/src/features/settings/lib/moderationQueue.ts +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -232,7 +232,8 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType { } /** - * Which one-click resolutions can actually be *enforced* for a given target. + * Which one-click resolutions can actually be *enforced* for a given target + * and viewer capabilities. * * A 9044 resolve only records the decision + DMs the reporter; the client must * compose the paired enforcement event (delete→9005, ban→9040, kick→9001). @@ -244,6 +245,7 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType { * only event-target reports (a pubkey report is not tied to a channel). * - `ban` (9040) needs only the author pubkey — event reports resolve it from * the reported event's signer; pubkey reports carry it as the target. + * Ban is **admin+ only** per the capability grid: hidden for moderators. * - `escalate` / `dismiss` are decision-only and always available. * * `timeout` is intentionally excluded until the resolve flow can collect a @@ -253,11 +255,14 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType { export function resolvableActions( targetKind: ReportTargetKind, hasChannel: boolean, + canBan = true, ): ResolutionAction[] { const actions: ResolutionAction[] = []; if (targetKind === "event" && hasChannel) actions.push("delete"); // ban needs only the author; event reports look it up from the signer. - if (targetKind === "event" || targetKind === "pubkey") actions.push("ban"); + // Gated by canBan so moderators never see the ban button. + if (canBan && (targetKind === "event" || targetKind === "pubkey")) + actions.push("ban"); if (targetKind === "event" && hasChannel) actions.push("kick"); actions.push("escalate", "dismiss"); return actions; diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index ca9adf980d..da1e9563af 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -12,11 +12,8 @@ import { } from "@/features/moderation/hooks"; import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { - deleteMessage, - getEventById, - removeChannelMember, -} from "@/shared/api/tauri"; +import { getEventById, removeChannelMember } from "@/shared/api/tauri"; +import { moderatorDeleteMessage } from "@/shared/api/moderator"; import { buildModerationQueue, groupTopReportType, @@ -30,6 +27,7 @@ import { type ReportType, type SeverityTier, } from "@/features/settings/lib/moderationQueue"; +import { moderationCapabilities } from "@/features/moderation/lib/capabilities"; import { cn } from "@/shared/lib/cn"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -115,7 +113,9 @@ async function enforceResolution( case "delete": // Gated to event targets with a channel (resolvableActions). if (group.channelId == null) throw new Error("Report has no channel."); - await deleteMessage(group.channelId, group.target); + // Use kind 9005 (moderator delete) since the queue actor may not own + // the message. The relay validates authority via the moderation seam. + await moderatorDeleteMessage(group.channelId, group.target); return; case "ban": await ban({ pubkey: await resolveTargetAuthor(group) }); @@ -280,11 +280,13 @@ function QueueGroupCard({ reporterNames, onResolve, disabled, + canBan, }: { group: ModerationQueueGroup; reporterNames: Record; onResolve: (group: ModerationQueueGroup, action: ResolutionAction) => void; disabled: boolean; + canBan: boolean; }) { const topType = groupTopReportType(group); const tier = severityTier(topType); @@ -321,6 +323,7 @@ function QueueGroupCard({ allowed={resolvableActions( group.targetKind, group.channelId != null, + canBan, )} disabled={disabled} onResolve={(action) => onResolve(group, action)} @@ -361,6 +364,8 @@ function QueueTab() { const auditQuery = useModerationAuditQuery(); const resolveMutation = useResolveReportMutation(); const banMutation = useBanMemberMutation(); + const membershipQuery = useMyRelayMembershipQuery(); + const caps = moderationCapabilities(membershipQuery.data?.role); const groups = useMemo(() => { const reports = (reportsQuery.data ?? []).map(toQueueReport); @@ -440,6 +445,7 @@ function QueueTab() {
{groups.map((group) => ( { + await invokeTauri("moderator_delete_message", { channelId, eventId }); +} diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts index 62cae9005a..f921702bd3 100644 --- a/desktop/src/shared/api/relayMembers.ts +++ b/desktop/src/shared/api/relayMembers.ts @@ -15,7 +15,12 @@ const KIND_RELAY_ADMIN_CHANGE_ROLE = 9032; function isRelayMemberRole( value: string | undefined, ): value is RelayMemberRole { - return value === "owner" || value === "admin" || value === "member"; + return ( + value === "owner" || + value === "admin" || + value === "moderator" || + value === "member" + ); } function normalizePubkey(pubkey: string): string { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 78f5d1aa3f..34fdf5ad92 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -258,7 +258,7 @@ export type { // ── Relay Members ──────────────────────────────────────────────────────────── -export type RelayMemberRole = "owner" | "admin" | "member"; +export type RelayMemberRole = "owner" | "admin" | "moderator" | "member"; export type RelayMember = { pubkey: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dc46d50ae5..b67ce23110 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -364,7 +364,7 @@ type E2eConfig = { relayRequiresMembership?: boolean; /** Delay EOSE for membership snapshots after delivering the event. */ relayMembershipEoseDelayMs?: number; - relayRole?: "owner" | "admin" | "member" | null; + relayRole?: "owner" | "admin" | "moderator" | "member" | null; // Descriptors returned by the mocked `pick_and_upload_media` / // `upload_media_bytes` commands. Lets a spec drive the attachment flow // (e.g. a generic PDF) without a real upload pipeline. See @@ -555,7 +555,7 @@ type RawBlobDescriptor = { type RawRelayMember = { pubkey: string; - role: "owner" | "admin" | "member"; + role: "owner" | "admin" | "moderator" | "member"; added_by: string | null; created_at: string; }; diff --git a/migrations/0028_relay_moderator_role.sql b/migrations/0028_relay_moderator_role.sql new file mode 100644 index 0000000000..e4671244d6 --- /dev/null +++ b/migrations/0028_relay_moderator_role.sql @@ -0,0 +1,23 @@ +-- ── Relay-level moderator role ──────────────────────────────────────────────── +-- Extends the `relay_members.role` CHECK to include 'moderator', enabling +-- community moderators who hold a scoped subset of admin capabilities +-- (ViewQueue, ResolveReport, DeleteMessage, Kick, Timeout, Untimeout) without +-- full relay-admin rights. +-- +-- Additive migration: previously applied files must not change checksum. +-- +-- PostgreSQL does not support ALTER TABLE ... ALTER CONSTRAINT, so we drop the +-- existing check constraint by name and add a new one. The constraint was +-- generated by the initial schema CREATE TABLE statement, so its name follows +-- the pg convention: relay_members_role_check. +-- +-- Lock note: DROP/ADD CONSTRAINT takes an ACCESS EXCLUSIVE lock on +-- relay_members for the duration. The table is not written to by live traffic +-- during migrations (embedded startup serializes schema-first before serving), +-- so this is safe in one-image deployments. Environments that separate +-- migration from runtime should schedule this during low-traffic windows. + +ALTER TABLE relay_members + DROP CONSTRAINT IF EXISTS relay_members_role_check, + ADD CONSTRAINT relay_members_role_check + CHECK (role IN ('owner', 'admin', 'member', 'moderator')); diff --git a/schema/schema.sql b/schema/schema.sql index 9f3449b066..8dfacaa082 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -572,7 +572,7 @@ CREATE TABLE pubkey_allowlist ( CREATE TABLE relay_members ( community_id UUID NOT NULL REFERENCES communities(id), pubkey TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'moderator')), added_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),