Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459//! `/admin/scripts` CRUD — trigger-keyed scripts.//!//! Each script row's `id` IS its trigger string (e.g.//! `record.create:com.example.thing`, `xrpc.query:com.foo.list`,//! `labeler.apply:_actor`). The dispatcher in [`crate::lua::scripts`]//! looks up scripts by id at firing time; this admin surface lets//! operators CRUD those rows.//!//! Validation://! - On create / patch the body is parsed against the script_type//! (lua → [`crate::lua::validate_script`]). Invalid bodies are//! rejected at write-time with a 400.//! - The trigger id is parsed against//! [`crate::lua::ParsedTrigger::parse`]; unknown prefixes / invalid//! NSIDs are rejected at write-time with a 400.//!//! Permissions: `scripts:read` for GETs; `scripts:manage` for the//! mutating endpoints.
use axum::Json;use axum::extract::{Path, State};use axum::http::StatusCode;use serde::{Deserialize, Serialize};
use crate::AppState;use crate::db::{adapt_sql, now_rfc3339};use crate::error::AppError;use crate::event_log::{EventLog, Severity, log_event};use crate::lua::{ParsedTrigger, ScriptLanguage};
use super::auth::UserAuth;use super::permissions::Permission;
const MAX_DESCRIPTION_LEN: usize = 300;
// ---------------------------------------------------------------------------// Wire types// ---------------------------------------------------------------------------
/// One row from the `scripts` table — what GET endpoints return.#[derive(Debug, Clone, Serialize)]pub(super) struct ScriptResponse { /// The trigger id; identifies the row. pub id: String, pub script_type: String, pub body: String, pub description: Option<String>, pub outbound_xrpcs: Option<Vec<String>>, pub created_at: String, pub updated_at: String,}
/// Body for `POST /admin/scripts` (create or replace by `id`).#[derive(Debug, Deserialize)]pub(super) struct UpsertBody { pub id: String, /// Defaults to `"lua"` server-side if omitted. #[serde(default)] pub script_type: Option<ScriptLanguage>, pub body: String, #[serde(default)] pub description: Option<String>,}
/// Body for `PATCH /admin/scripts/{id}`. All fields optional.#[derive(Debug, Deserialize)]pub(super) struct PatchBody { #[serde(default)] pub script_type: Option<ScriptLanguage>, #[serde(default)] pub body: Option<String>, /// Set to `Some(None)` to clear via JSON `null`. #[serde(default, deserialize_with = "deserialize_optional_field")] pub description: Option<Option<String>>,}
/// Three-state field deserializer: missing → `None`, `null` → `Some(None)`,/// string → `Some(Some(s))`. Lets PATCH distinguish "leave as-is" from/// "clear to NULL".fn deserialize_optional_field<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>where D: serde::Deserializer<'de>,{ let v: Option<String> = Option::deserialize(d)?; Ok(Some(v))}
// ---------------------------------------------------------------------------// Routes// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]pub(super) struct ListQuery { pub suffix: Option<String>,}
/// `GET /admin/scripts` — list all rows. Clients group by trigger family/// in the UI. Optional `?suffix=<nsid>` filters to scripts whose id/// ends with `:<suffix>`.pub(super) async fn list( State(state): State<AppState>, auth: UserAuth, axum::extract::Query(query): axum::extract::Query<ListQuery>,) -> Result<Json<Vec<ScriptResponse>>, AppError> { auth.require(Permission::ScriptsRead).await?;
let backend = state.db_backend; let mut sql = String::from( "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at FROM happyview_scripts", ); if query.suffix.is_some() { sql.push_str(" WHERE id LIKE ?"); } sql.push_str(" ORDER BY id");
let sql = adapt_sql(&sql, backend); #[allow(clippy::type_complexity)] let mut q = sqlx::query_as::< _, ( String, String, String, Option<String>, Option<String>, String, String, ), >(&sql); if let Some(ref suffix) = query.suffix { q = q.bind(format!("%:{suffix}")); } let rows = q .fetch_all(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to list scripts: {e}")))?;
let scripts: Vec<ScriptResponse> = rows .into_iter() .map( |(id, script_type, body, description, outbound_xrpcs_json, created_at, updated_at)| { let outbound_xrpcs: Option<Vec<String>> = outbound_xrpcs_json.and_then(|j| serde_json::from_str(&j).ok()); ScriptResponse { id, script_type, body, description, outbound_xrpcs, created_at, updated_at, } }, ) .collect();
Ok(Json(scripts))}
/// `GET /admin/scripts/{id}` — fetch one row.pub(super) async fn get( State(state): State<AppState>, auth: UserAuth, Path(id): Path<String>,) -> Result<Json<ScriptResponse>, AppError> { auth.require(Permission::ScriptsRead).await?; fetch_one(&state, &id).await.map(Json)}
/// `POST /admin/scripts` — create or replace a row by `id`. Returns the/// upserted row. Status `201 Created` for a new row, `200 OK` for an/// update.pub(super) async fn upsert( State(state): State<AppState>, auth: UserAuth, Json(body): Json<UpsertBody>,) -> Result<(StatusCode, Json<ScriptResponse>), AppError> { auth.require(Permission::ScriptsManage).await?;
// Validate the trigger id grammar up-front (400 with a clear message). let _trigger = ParsedTrigger::parse(&body.id).map_err(AppError::BadRequest)?;
if let Some(ref desc) = body.description && desc.len() > MAX_DESCRIPTION_LEN { return Err(AppError::BadRequest(format!( "description must be at most {MAX_DESCRIPTION_LEN} characters" ))); }
let script_type = body.script_type.unwrap_or_default(); validate_body_for_type(&body.body, script_type)?;
let outbound_xrpcs = crate::lua_analysis::extract_outbound_xrpcs(&body.body); let outbound_json = if outbound_xrpcs.is_empty() { None } else { Some(serde_json::to_string(&outbound_xrpcs).map_err(|e| { AppError::Internal(format!("failed to serialize outbound xrpcs: {e}")) })?) };
let backend = state.db_backend; let now = now_rfc3339(); let description = body.description.as_deref().filter(|s| !s.is_empty());
// Distinguish create vs update so we can return 201 vs 200. let pre_exists: Option<(String,)> = sqlx::query_as(&adapt_sql( "SELECT id FROM happyview_scripts WHERE id = ?", backend, )) .bind(&body.id) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to check script existence: {e}")))?; let was_new = pre_exists.is_none();
let sql = adapt_sql( r#" INSERT INTO happyview_scripts (id, script_type, body, description, outbound_xrpcs, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET script_type = EXCLUDED.script_type, body = EXCLUDED.body, description = EXCLUDED.description, outbound_xrpcs = EXCLUDED.outbound_xrpcs, updated_at = EXCLUDED.updated_at "#, backend, ); sqlx::query(&sql) .bind(&body.id) .bind(script_type.as_str()) .bind(&body.body) .bind(description) .bind(&outbound_json) .bind(&now) .bind(&now) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert script: {e}")))?;
log_event( &state.db, EventLog { event_type: if was_new { "script.created".to_string() } else { "script.updated".to_string() }, severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(body.id.clone()), detail: serde_json::json!({ "script_type": script_type.as_str(), }), }, backend, ) .await;
let row = fetch_one(&state, &body.id).await?; let status = if was_new { StatusCode::CREATED } else { StatusCode::OK }; Ok((status, Json(row)))}
/// `PATCH /admin/scripts/{id}` — partial update. At least one of/// `script_type` / `body` / `description` must be present.pub(super) async fn patch( State(state): State<AppState>, auth: UserAuth, Path(id): Path<String>, Json(body): Json<PatchBody>,) -> Result<Json<ScriptResponse>, AppError> { auth.require(Permission::ScriptsManage).await?;
if body.script_type.is_none() && body.body.is_none() && body.description.is_none() { return Err(AppError::BadRequest( "patch requires at least one of: script_type, body, description".into(), )); } // Patching a body or script_type? We need a body to validate against // the (possibly new) language. Patching script_type alone is // ambiguous (we'd be validating the existing body against the new // language without re-checking it makes sense), so reject it. if body.script_type.is_some() && body.body.is_none() { return Err(AppError::BadRequest( "patching script_type requires body alongside (so the server can re-validate)".into(), )); } if let Some(ref new_body) = body.body { let lang = body.script_type.unwrap_or_default(); validate_body_for_type(new_body, lang)?; } if let Some(Some(ref desc)) = body.description && desc.len() > MAX_DESCRIPTION_LEN { return Err(AppError::BadRequest(format!( "description must be at most {MAX_DESCRIPTION_LEN} characters" ))); }
// Existence check + fetch current values. let existing = fetch_one(&state, &id).await?;
let backend = state.db_backend; let now = now_rfc3339(); let new_script_type = body .script_type .map(|s| s.as_str().to_string()) .unwrap_or(existing.script_type); let new_body = body.body.unwrap_or(existing.body); let new_description = match body.description { Some(desc_opt) => desc_opt, None => existing.description, };
let outbound_xrpcs = crate::lua_analysis::extract_outbound_xrpcs(&new_body); let outbound_json: Option<String> = if outbound_xrpcs.is_empty() { None } else { Some(serde_json::to_string(&outbound_xrpcs).map_err(|e| { AppError::Internal(format!("failed to serialize outbound xrpcs: {e}")) })?) };
let sql = adapt_sql( r#" UPDATE happyview_scripts SET script_type = ?, body = ?, description = ?, outbound_xrpcs = ?, updated_at = ? WHERE id = ? "#, backend, ); sqlx::query(&sql) .bind(&new_script_type) .bind(&new_body) .bind(new_description.as_deref()) .bind(&outbound_json) .bind(&now) .bind(&id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to patch script: {e}")))?;
log_event( &state.db, EventLog { event_type: "script.updated".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(id.clone()), detail: serde_json::json!({ "script_type": new_script_type, }), }, backend, ) .await;
let row = fetch_one(&state, &id).await?; Ok(Json(row))}
/// `DELETE /admin/scripts/{id}` — remove a row. 204 on success, 404 if/// no row matched.pub(super) async fn delete( State(state): State<AppState>, auth: UserAuth, Path(id): Path<String>,) -> Result<StatusCode, AppError> { auth.require(Permission::ScriptsManage).await?;
let backend = state.db_backend; let sql = adapt_sql("DELETE FROM happyview_scripts WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(&id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to delete script: {e}")))?; if result.rows_affected() == 0 { return Err(AppError::NotFound(format!("script '{id}' not found"))); }
log_event( &state.db, EventLog { event_type: "script.deleted".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(id), detail: serde_json::json!({}), }, backend, ) .await; Ok(StatusCode::NO_CONTENT)}
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
/// Look up a single script row; 404 if missing.async fn fetch_one(state: &AppState, id: &str) -> Result<ScriptResponse, AppError> { let backend = state.db_backend; let sql = adapt_sql( "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at FROM happyview_scripts WHERE id = ?", backend, ); #[allow(clippy::type_complexity)] let row: Option<( String, String, String, Option<String>, Option<String>, String, String, )> = sqlx::query_as(&sql) .bind(id) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to fetch script: {e}")))?; let (id, script_type, body, description, outbound_xrpcs_json, created_at, updated_at) = row.ok_or_else(|| AppError::NotFound(format!("script '{id}' not found")))?; let outbound_xrpcs: Option<Vec<String>> = outbound_xrpcs_json.and_then(|j| serde_json::from_str(&j).ok()); Ok(ScriptResponse { id, script_type, body, description, outbound_xrpcs, created_at, updated_at, })}
/// Validate the script body against its declared language. Rejects/// invalid bodies with a 400 at write-time.fn validate_body_for_type(body: &str, lang: ScriptLanguage) -> Result<(), AppError> { match lang { ScriptLanguage::Lua => crate::lua::validate_script(body).map_err(AppError::BadRequest), }}