//! Lua bindings for labeler + moderation operations. //! //! Scripts attached to service hooks use these to emit labels and resolve //! reports as side effects. They thinly wrap the existing //! `labeler::emitter::{emit, negate}` and `moderation::store::{resolve, get, //! list}` paths so all the same validation, signing, and broadcasting still //! happens. //! //! IMPORTANT: these calls are unauthenticated from Lua's perspective — the //! hook caller has already passed admin/XRPC auth at the originating //! request, and the script runs server-side. Don't expose these APIs to //! query/procedure scripts that take untrusted input directly. //! //! Re-entry note: `labeler.emit` from a `report.created` hook will trigger //! the `label.emitted` hook chain. The instruction-count limit and the //! per-hook retry budget bound runaway loops, but scripts should still //! avoid recursive emission by design. use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; use std::sync::Arc; use crate::AppState; use crate::labeler::emitter::{self, EmitRequest, NegateRequest}; use crate::lua::service_hooks::{self, LabelEmittedEvent, ReportResolvedEvent}; use crate::moderation::store::{self, ListFilter, Sort}; use crate::moderation::types::ReportStatus; /// Register the `labeler` table with `emit` and `negate` async functions. pub fn register_labeler_api(lua: &Lua, state: Arc) -> LuaResult<()> { let labeler = lua.create_table()?; let state_emit = state.clone(); let emit_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_emit.clone(); async move { let uri: String = opts.get("uri").map_err(|e| { mlua::Error::runtime(format!("labeler.emit: missing 'uri': {e}")) })?; let val: String = opts.get("val").map_err(|e| { mlua::Error::runtime(format!("labeler.emit: missing 'val': {e}")) })?; let cid: Option = opts.get("cid").ok(); let exp: Option = opts.get("exp").ok(); // Fire the label.emitted hook chain — same as the admin path. // A nested labeler.emit from a hook will recursively re-enter // here, but the per-script instruction limit + retry budget // bound runaway. Scripts should still avoid recursive emission. let event = service_hooks::dispatch_label_emitted( &state, LabelEmittedEvent { uri, cid, val, neg: false, exp, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.emit: {e}")))?; let identity = state.labeler_identity.read().await.clone(); let label = emitter::emit( &state.db, state.db_backend, identity.as_ref(), &state.labeler_definitions, &state.emitted_label_tx, EmitRequest { uri: event.uri, val: event.val, cid: event.cid, exp: event.exp, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.emit: {e}")))?; // Hand back a table mirroring the persisted label. `sig` is hex. let result = lua.create_table()?; result.set("seq", label.seq)?; result.set("src", label.src)?; result.set("uri", label.uri)?; if let Some(c) = label.cid { result.set("cid", c)?; } result.set("val", label.val)?; result.set("neg", label.neg)?; result.set("cts", label.cts)?; if let Some(e) = label.exp { result.set("exp", e)?; } result.set("sig", hex::encode(&label.sig))?; Ok(mlua::Value::Table(result)) } })?; labeler.set("emit", emit_fn)?; let state_negate = state.clone(); let negate_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_negate.clone(); async move { let uri: String = opts.get("uri").map_err(|e| { mlua::Error::runtime(format!("labeler.negate: missing 'uri': {e}")) })?; let val: String = opts.get("val").map_err(|e| { mlua::Error::runtime(format!("labeler.negate: missing 'val': {e}")) })?; // Fire the label.emitted hook chain with neg=true. Hooks can // tell apply from negate via `event.neg`. let event = service_hooks::dispatch_label_emitted( &state, LabelEmittedEvent { uri, cid: None, val, neg: true, exp: None, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.negate: {e}")))?; let identity = state.labeler_identity.read().await.clone(); let label = emitter::negate( &state.db, state.db_backend, identity.as_ref(), &state.labeler_definitions, &state.emitted_label_tx, NegateRequest { uri: event.uri, val: event.val, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.negate: {e}")))?; let result = lua.create_table()?; result.set("seq", label.seq)?; result.set("src", label.src)?; result.set("uri", label.uri)?; result.set("val", label.val)?; result.set("neg", label.neg)?; result.set("cts", label.cts)?; result.set("sig", hex::encode(&label.sig))?; Ok(mlua::Value::Table(result)) } })?; labeler.set("negate", negate_fn)?; lua.globals().set("labeler", labeler)?; Ok(()) } /// Register the `moderation` table with report-management functions. pub fn register_moderation_api(lua: &Lua, state: Arc) -> LuaResult<()> { let moderation = lua.create_table()?; // moderation.action(id, note?, by?) — flip status to actioned. Fires // the report.resolved hook chain so downstream hooks see the // transition uniformly with admin-driven resolutions. { let state = state.clone(); let func = lua.create_async_function( move |lua, (id, note, by): (i64, Option, Option)| { let state = state.clone(); async move { let by = by.unwrap_or_else(|| "service-hook".to_string()); let report = resolve_with_hooks(&state, id, ReportStatus::Actioned, by, note).await?; let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize report: {e}")))?; lua.to_value(&value).map_err(|e| { mlua::Error::runtime(format!("convert report to lua: {e}")) }) } }, )?; moderation.set("action", func)?; } // moderation.dismiss(id, note?, by?) — flip status to dismissed. { let state = state.clone(); let func = lua.create_async_function( move |lua, (id, note, by): (i64, Option, Option)| { let state = state.clone(); async move { let by = by.unwrap_or_else(|| "service-hook".to_string()); let report = resolve_with_hooks(&state, id, ReportStatus::Dismissed, by, note).await?; let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize report: {e}")))?; lua.to_value(&value).map_err(|e| { mlua::Error::runtime(format!("convert report to lua: {e}")) }) } }, )?; moderation.set("dismiss", func)?; } // moderation.get_report(id) — fetch a report by id (nil on not-found) { let state = state.clone(); let func = lua.create_async_function(move |lua, id: i64| { let state = state.clone(); async move { match store::get(&state.db, state.db_backend, id).await { Ok(report) => { let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize: {e}")))?; lua.to_value(&value) .map_err(|e| mlua::Error::runtime(format!("convert: {e}"))) } Err(store::StoreError::NotFound) => Ok(mlua::Value::Nil), Err(e) => Err(mlua::Error::runtime(format!("moderation.get_report: {e}"))), } } })?; moderation.set("get_report", func)?; } // moderation.list_reports({ status?, subject_did?, subject_uri?, // reporter_did?, limit?, sort? }) // → { reports = [...], cursor = | nil } { let state = state.clone(); let func = lua.create_async_function(move |lua, opts: Option| { let state = state.clone(); async move { let opts = opts.unwrap_or(lua.create_table()?); let mut filter = ListFilter { limit: opts.get::("limit").unwrap_or(50), ..Default::default() }; if let Ok(s) = opts.get::("status") { filter.status = ReportStatus::from_str(&s); if filter.status.is_none() { return Err(mlua::Error::runtime(format!( "moderation.list_reports: invalid status '{s}' \ (open|actioned|dismissed)" ))); } } if let Ok(s) = opts.get::("subject_did") { filter.subject_did = Some(s); } if let Ok(s) = opts.get::("subject_uri") { filter.subject_uri = Some(s); } if let Ok(s) = opts.get::("reporter_did") { filter.reporter_did = Some(s); } if let Ok(c) = opts.get::("cursor") { filter.cursor = Some(c); } if let Ok(s) = opts.get::("sort") { filter.sort = Sort::from_str(&s).ok_or_else(|| { mlua::Error::runtime(format!( "moderation.list_reports: invalid sort '{s}' \ (newest|oldest|subject)" )) })?; } let (reports, next_cursor) = store::list(&state.db, state.db_backend, &filter) .await .map_err(|e| { mlua::Error::runtime(format!("moderation.list_reports: {e}")) })?; let result = lua.create_table()?; let reports_value = serde_json::to_value(&reports) .map_err(|e| mlua::Error::runtime(format!("serialize reports: {e}")))?; let reports_lua = lua.to_value(&reports_value).map_err(|e| { mlua::Error::runtime(format!("convert reports to lua: {e}")) })?; // Force array shape when empty so json.encode round-trips. if let mlua::Value::Table(t) = &reports_lua { t.set_metatable(Some(lua.array_metatable()))?; } result.set("reports", reports_lua)?; if let Some(c) = next_cursor { result.set("cursor", c)?; } Ok(mlua::Value::Table(result)) } })?; moderation.set("list_reports", func)?; } // moderation.list_subjects({ only_open?, limit? }) — aggregated view { let state = state.clone(); let func = lua.create_async_function(move |lua, opts: Option| { let state = state.clone(); async move { let opts = opts.unwrap_or(lua.create_table()?); let only_open: bool = opts.get("only_open").unwrap_or(false); let limit: i64 = opts.get("limit").unwrap_or(50); let summaries = store::list_subjects(&state.db, state.db_backend, only_open, limit) .await .map_err(|e| { mlua::Error::runtime(format!("moderation.list_subjects: {e}")) })?; let value = serde_json::to_value(&summaries) .map_err(|e| mlua::Error::runtime(format!("serialize subjects: {e}")))?; let v = lua .to_value(&value) .map_err(|e| mlua::Error::runtime(format!("convert subjects: {e}")))?; if let mlua::Value::Table(t) = &v { t.set_metatable(Some(lua.array_metatable()))?; } Ok(v) } })?; moderation.set("list_subjects", func)?; } lua.globals().set("moderation", moderation)?; Ok(()) } /// Shared `moderation.action` / `moderation.dismiss` body: load the /// current report, fire the report.resolved hook chain, then write. async fn resolve_with_hooks( state: &Arc, id: i64, new_status: ReportStatus, resolved_by: String, note: Option, ) -> mlua::Result { let current = store::get(&state.db, state.db_backend, id) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", new_status.as_str())))?; let event = service_hooks::dispatch_report_resolved( state, ReportResolvedEvent { report: current, new_status, resolved_by, note, }, ) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", new_status.as_str())))?; store::resolve( &state.db, state.db_backend, id, event.new_status, &event.resolved_by, event.note.as_deref(), ) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", event.new_status.as_str()))) }