use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::PgPool; use uuid::Uuid; use crate::plugins::{ hooks::{HookContext, PluginError}, manager::{Plugin, PluginMetadata, PluginScope, PluginSystem}, }; /// Moderation Ledger Plugin /// /// Creates an immutable, cryptographically-chained log of all moderation decisions. /// This plugin is NON-DEACTIVATABLE by design - transparency is not optional. /// /// Features: /// - Immutable entries with SHA-256 hash chain /// - Full audit trail with actor, target, reason, evidence /// - Chain verification for tamper detection /// - Export to JSON/CSV /// - Community-scoped with independent chains pub struct ModerationLedgerPlugin; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ModerationActionType { // Content moderation ContentRemove, ContentHide, ContentRestore, ContentEdit, ContentFlag, ContentUnflag, // User moderation UserWarn, UserMute, UserUnmute, UserSuspend, UserUnsuspend, UserBan, UserUnban, UserRoleChange, // Community moderation CommunitySettingChange, CommunityRuleAdd, CommunityRuleEdit, CommunityRuleRemove, // Proposal/voting moderation ProposalClose, ProposalReopen, ProposalArchive, VoteInvalidate, VoteRestore, // Escalation EscalateToAdmin, EscalateToCommunity, AppealReceived, AppealResolved, } impl ModerationActionType { pub fn as_db_str(&self) -> &'static str { match self { Self::ContentRemove => "content_remove", Self::ContentHide => "content_hide", Self::ContentRestore => "content_restore", Self::ContentEdit => "content_edit", Self::ContentFlag => "content_flag", Self::ContentUnflag => "content_unflag", Self::UserWarn => "user_warn", Self::UserMute => "user_mute", Self::UserUnmute => "user_unmute", Self::UserSuspend => "user_suspend", Self::UserUnsuspend => "user_unsuspend", Self::UserBan => "user_ban", Self::UserUnban => "user_unban", Self::UserRoleChange => "user_role_change", Self::CommunitySettingChange => "community_setting_change", Self::CommunityRuleAdd => "community_rule_add", Self::CommunityRuleEdit => "community_rule_edit", Self::CommunityRuleRemove => "community_rule_remove", Self::ProposalClose => "proposal_close", Self::ProposalReopen => "proposal_reopen", Self::ProposalArchive => "proposal_archive", Self::VoteInvalidate => "vote_invalidate", Self::VoteRestore => "vote_restore", Self::EscalateToAdmin => "escalate_to_admin", Self::EscalateToCommunity => "escalate_to_community", Self::AppealReceived => "appeal_received", Self::AppealResolved => "appeal_resolved", } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LedgerEntry { pub id: Uuid, pub sequence_number: i64, pub community_id: Option, pub actor_user_id: Uuid, pub actor_role: String, pub action_type: String, pub target_type: String, pub target_id: Uuid, pub reason: String, pub rule_reference: Option, pub evidence: Option, pub duration_hours: Option, pub decision_type: String, pub entry_hash: String, pub created_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainVerificationResult { pub is_valid: bool, pub total_entries: i64, pub broken_at_sequence: Option, pub expected_hash: Option, pub actual_hash: Option, pub error_message: Option, } /// Service for interacting with the moderation ledger pub struct LedgerService; impl LedgerService { /// Create a new ledger entry #[allow(clippy::too_many_arguments)] pub async fn create_entry( pool: &PgPool, community_id: Option, actor_user_id: Uuid, actor_role: &str, action_type: ModerationActionType, target_type: &str, target_id: Uuid, reason: &str, rule_reference: Option<&str>, evidence: Option, target_snapshot: Option, duration_hours: Option, decision_type: &str, vote_proposal_id: Option, vote_result: Option, ) -> Result { let entry_id: Uuid = sqlx::query_scalar( r#"SELECT create_ledger_entry( $1, $2, $3, $4::moderation_action_type, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 )"#, ) .bind(community_id) .bind(actor_user_id) .bind(actor_role) .bind(action_type.as_db_str()) .bind(target_type) .bind(target_id) .bind(reason) .bind(rule_reference) .bind(evidence) .bind(target_snapshot) .bind(duration_hours) .bind(decision_type) .bind(vote_proposal_id) .bind(vote_result) .fetch_one(pool) .await?; Ok(entry_id) } /// Get ledger entries for a community pub async fn get_entries( pool: &PgPool, community_id: Option, limit: i64, offset: i64, ) -> Result, PluginError> { let entries = sqlx::query_as!( LedgerEntry, r#"SELECT id, sequence_number, community_id, actor_user_id, actor_role, action_type::text AS "action_type!", target_type, target_id, reason, rule_reference, evidence, duration_hours, decision_type, entry_hash, created_at FROM moderation_ledger WHERE community_id IS NOT DISTINCT FROM $1 ORDER BY sequence_number DESC LIMIT $2 OFFSET $3"#, community_id, limit, offset, ) .fetch_all(pool) .await?; Ok(entries) } /// Get a single entry by ID pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result, PluginError> { let entry = sqlx::query_as!( LedgerEntry, r#"SELECT id, sequence_number, community_id, actor_user_id, actor_role, action_type::text AS "action_type!", target_type, target_id, reason, rule_reference, evidence, duration_hours, decision_type, entry_hash, created_at FROM moderation_ledger WHERE id = $1"#, entry_id, ) .fetch_optional(pool) .await?; Ok(entry) } /// Get entries targeting a specific entity pub async fn get_entries_for_target( pool: &PgPool, target_type: &str, target_id: Uuid, ) -> Result, PluginError> { let entries = sqlx::query_as!( LedgerEntry, r#"SELECT id, sequence_number, community_id, actor_user_id, actor_role, action_type::text AS "action_type!", target_type, target_id, reason, rule_reference, evidence, duration_hours, decision_type, entry_hash, created_at FROM moderation_ledger WHERE target_type = $1 AND target_id = $2 ORDER BY sequence_number DESC"#, target_type, target_id, ) .fetch_all(pool) .await?; Ok(entries) } /// Verify the chain integrity pub async fn verify_chain( pool: &PgPool, community_id: Option, ) -> Result { let result = sqlx::query!( r#"SELECT is_valid, total_entries, broken_at_sequence, expected_hash, actual_hash, error_message FROM verify_ledger_chain($1)"#, community_id, ) .fetch_one(pool) .await?; Ok(ChainVerificationResult { is_valid: result.is_valid.unwrap_or(false), total_entries: result.total_entries.unwrap_or(0), broken_at_sequence: result.broken_at_sequence, expected_hash: result.expected_hash, actual_hash: result.actual_hash, error_message: result.error_message, }) } /// Get statistics for a community pub async fn get_stats( pool: &PgPool, community_id: Option, ) -> Result { let stats = sqlx::query!( r#"SELECT action_type::text AS action_type, decision_type, total_actions, unique_actors, unique_targets FROM v_moderation_stats WHERE community_id IS NOT DISTINCT FROM $1"#, community_id, ) .fetch_all(pool) .await?; let summary: Vec = stats .into_iter() .map(|s| { json!({ "action_type": s.action_type, "decision_type": s.decision_type, "total_actions": s.total_actions, "unique_actors": s.unique_actors, "unique_targets": s.unique_targets, }) }) .collect(); Ok(json!({ "stats": summary })) } /// Export ledger entries as JSON pub async fn export_json( pool: &PgPool, community_id: Option, ) -> Result { let entries = Self::get_entries(pool, community_id, 100000, 0).await?; let verification = Self::verify_chain(pool, community_id).await?; Ok(json!({ "export_version": "1.0", "exported_at": chrono::Utc::now(), "community_id": community_id, "chain_verification": verification, "entries": entries, })) } } #[async_trait] impl Plugin for ModerationLedgerPlugin { fn metadata(&self) -> PluginMetadata { PluginMetadata { name: "moderation_ledger", version: "1.0.0", description: "Immutable, cryptographically-chained log of all moderation decisions. Cannot be deactivated.", is_core: true, // Core plugin - cannot be disabled scope: PluginScope::Global, default_enabled: true, settings_schema: Some(json!({ "type": "object", "properties": { "require_reason_min_length": { "type": "integer", "title": "Minimum reason length", "description": "Minimum characters required for moderation justifications", "default": 20, "minimum": 10, "maximum": 500 }, "require_rule_reference": { "type": "boolean", "title": "Require rule reference", "description": "Require moderators to cite a specific community rule", "default": false }, "public_ledger": { "type": "boolean", "title": "Public ledger", "description": "Allow all community members to view the moderation ledger", "default": true } } })), } } fn register(&self, system: &mut PluginSystem) { let plugin_name = self.metadata().name.to_string(); // Hook: Log content removal system.add_action( "moderation.content_removed", plugin_name.clone(), 1, // Highest priority - must log before anything else Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name.clone(); Box::pin(async move { let actor_id = ctx.actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let target_id = payload .get("content_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .ok_or_else(|| PluginError::Message("Missing content_id".into()))?; let content_type = payload .get("content_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let reason = payload .get("reason") .and_then(|v| v.as_str()) .unwrap_or("No reason provided"); let actor_role = payload .get("actor_role") .and_then(|v| v.as_str()) .unwrap_or("moderator"); let entry_id = LedgerService::create_entry( &ctx.pool, ctx.community_id, actor_id, actor_role, ModerationActionType::ContentRemove, content_type, target_id, reason, payload.get("rule_reference").and_then(|v| v.as_str()), payload.get("evidence").cloned(), payload.get("content_snapshot").cloned(), None, "unilateral", None, None, ).await?; let _ = ctx.emit_public_event( Some(&plugin_name), "ledger.entry_created", json!({ "entry_id": entry_id, "action_type": "content_remove", "target_type": content_type, }), ).await; Ok(()) }) }), ); // Hook: Log user moderation let plugin_name2 = self.metadata().name.to_string(); system.add_action( "moderation.user_action", plugin_name2.clone(), 1, Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name2.clone(); Box::pin(async move { let actor_id = ctx.actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let target_user_id = payload .get("target_user_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .ok_or_else(|| PluginError::Message("Missing target_user_id".into()))?; let action = payload .get("action") .and_then(|v| v.as_str()) .unwrap_or("user_warn"); let action_type = match action { "warn" => ModerationActionType::UserWarn, "mute" => ModerationActionType::UserMute, "unmute" => ModerationActionType::UserUnmute, "suspend" => ModerationActionType::UserSuspend, "unsuspend" => ModerationActionType::UserUnsuspend, "ban" => ModerationActionType::UserBan, "unban" => ModerationActionType::UserUnban, _ => ModerationActionType::UserWarn, }; let reason = payload .get("reason") .and_then(|v| v.as_str()) .unwrap_or("No reason provided"); let actor_role = payload .get("actor_role") .and_then(|v| v.as_str()) .unwrap_or("moderator"); let duration = payload .get("duration_hours") .and_then(|v| v.as_i64()) .map(|d| d as i32); let entry_id = LedgerService::create_entry( &ctx.pool, ctx.community_id, actor_id, actor_role, action_type, "user", target_user_id, reason, payload.get("rule_reference").and_then(|v| v.as_str()), payload.get("evidence").cloned(), None, duration, "unilateral", None, None, ).await?; let _ = ctx.emit_public_event( Some(&plugin_name), "ledger.entry_created", json!({ "entry_id": entry_id, "action_type": action, "target_type": "user", }), ).await; Ok(()) }) }), ); // Hook: Log proposal moderation let plugin_name3 = self.metadata().name.to_string(); system.add_action( "moderation.proposal_action", plugin_name3.clone(), 1, Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name3.clone(); Box::pin(async move { let actor_id = ctx.actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let proposal_id = payload .get("proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .ok_or_else(|| PluginError::Message("Missing proposal_id".into()))?; let action = payload .get("action") .and_then(|v| v.as_str()) .unwrap_or("close"); let action_type = match action { "close" => ModerationActionType::ProposalClose, "reopen" => ModerationActionType::ProposalReopen, "archive" => ModerationActionType::ProposalArchive, _ => ModerationActionType::ProposalClose, }; let reason = payload .get("reason") .and_then(|v| v.as_str()) .unwrap_or("No reason provided"); let actor_role = payload .get("actor_role") .and_then(|v| v.as_str()) .unwrap_or("moderator"); let decision_type = payload .get("decision_type") .and_then(|v| v.as_str()) .unwrap_or("unilateral"); let vote_proposal_id = payload .get("vote_proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()); let entry_id = LedgerService::create_entry( &ctx.pool, ctx.community_id, actor_id, actor_role, action_type, "proposal", proposal_id, reason, payload.get("rule_reference").and_then(|v| v.as_str()), payload.get("evidence").cloned(), payload.get("proposal_snapshot").cloned(), None, decision_type, vote_proposal_id, payload.get("vote_result").cloned(), ).await?; let _ = ctx.emit_public_event( Some(&plugin_name), "ledger.entry_created", json!({ "entry_id": entry_id, "action_type": action, "target_type": "proposal", }), ).await; Ok(()) }) }), ); } }