diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,16 @@ "generic-array", ] [[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -464,12 +474,29 @@ "syn", ] [[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -762,6 +789,7 @@ "futures-util", "hickory-resolver", "http-body-util", "jsonwebtoken", + "mlua", "p256", "reqwest", "serde", @@ -1307,6 +1335,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lua-src" +version = "550.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.6+707c12b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" +dependencies = [ + "cc", + "which", +] + +[[package]] name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1365,6 +1412,40 @@ "windows-sys 0.61.2", ] [[package]] +name = "mlua" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" +dependencies = [ + "bstr", + "either", + "erased-serde", + "futures-util", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] name = "moka" version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1531,6 +1612,15 @@ "cc", "libc", "pkg-config", "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", ] [[package]] @@ -1908,6 +1998,12 @@ "zeroize", ] [[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2050,6 +2146,16 @@ "serde_derive", ] [[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2872,6 +2978,12 @@ "utf-8", ] [[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3139,6 +3251,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", ] [[package]] @@ -3458,6 +3581,12 @@ dependencies = [ "cfg-if", "windows-sys 0.48.0", ] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wiremock" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } hickory-resolver = "0.25" +mlua = { version = "0.11", features = ["lua54", "async", "serialize", "vendored", "send"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/migrations/20260222000000_add_lexicon_script.sql b/migrations/20260222000000_add_lexicon_script.sql new file mode 100644 --- /dev/null +++ b/migrations/20260222000000_add_lexicon_script.sql @@ -0,0 +1,1 @@ +ALTER TABLE lexicons ADD COLUMN script TEXT; diff --git a/src/admin/lexicons.rs b/src/admin/lexicons.rs --- a/src/admin/lexicons.rs +++ b/src/admin/lexicons.rs @@ -55,21 +55,28 @@ body.lexicon_json.clone(), 1, body.target_collection.clone(), action.clone(), + body.script.clone(), ) .map_err(|e| AppError::BadRequest(format!("failed to parse lexicon: {e}")))?; + // Validate script if provided + if let Some(ref script) = body.script { + crate::lua::validate_script(script).map_err(AppError::BadRequest)?; + } + let action_str = action.to_optional_str(); // Upsert into database let row: (i32,) = sqlx::query_as( r#" - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, source) - VALUES ($1, $2, $3, $4, $5, 'manual') + INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, script, source) + VALUES ($1, $2, $3, $4, $5, $6, 'manual') ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, backfill = EXCLUDED.backfill, target_collection = EXCLUDED.target_collection, action = EXCLUDED.action, + script = EXCLUDED.script, source = 'manual', revision = lexicons.revision + 1, updated_at = NOW() @@ -81,6 +88,7 @@ .bind(&body.lexicon_json) .bind(body.backfill) .bind(&body.target_collection) .bind(action_str) + .bind(&body.script) .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert lexicon: {e}")))?; @@ -88,8 +96,14 @@ let revision = row.0; // Update in-memory registry with correct revision - let parsed = ParsedLexicon::parse(body.lexicon_json, revision, body.target_collection, action) - .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; + let parsed = ParsedLexicon::parse( + body.lexicon_json, + revision, + body.target_collection, + action, + body.script, + ) + .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; let is_record = parsed.lexicon_type == LexiconType::Record; state.lexicons.upsert(parsed).await; @@ -118,9 +132,9 @@ State(state): State, _admin: AdminAuth, ) -> Result>, AppError> { #[allow(clippy::type_complexity)] - let rows: Vec<(String, i32, Value, bool, Option, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = + let rows: Vec<(String, i32, Value, bool, Option, Option, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = sqlx::query_as( - "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons ORDER BY id", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons ORDER BY id", ) .fetch_all(&state.db) .await @@ -136,16 +150,23 @@ json, backfill, action, target_collection, + script, source, authority_did, last_fetched_at, created_at, updated_at, )| { - let lexicon_type = - ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert) - .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) - .unwrap_or_else(|_| "unknown".into()); + let parsed = + ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert, None); + let lexicon_type = parsed + .as_ref() + .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) + .unwrap_or_else(|_| "unknown".into()); + let record_schema = parsed + .ok() + .filter(|p| p.lexicon_type == LexiconType::Record) + .and_then(|p| p.record_schema); LexiconSummary { id, @@ -154,11 +175,13 @@ lexicon_type, backfill, action, target_collection, + has_script: script.is_some(), source, authority_did, last_fetched_at, created_at, updated_at, + record_schema, } }, ) @@ -174,9 +197,9 @@ _admin: AdminAuth, Path(id): Path, ) -> Result, AppError> { #[allow(clippy::type_complexity)] - let row: Option<(String, i32, Value, bool, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = + let row: Option<(String, i32, Value, bool, Option, Option, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = sqlx::query_as( - "SELECT id, revision, lexicon_json, backfill, action, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons WHERE id = $1", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons WHERE id = $1", ) .bind(&id) .fetch_optional(&state.db) @@ -189,6 +212,8 @@ revision, lexicon_json, backfill, action, + target_collection, + script, source, authority_did, last_fetched_at, @@ -196,12 +221,28 @@ created_at, updated_at, ) = row.ok_or_else(|| AppError::NotFound(format!("lexicon '{id}' not found")))?; + let lexicon_type = ParsedLexicon::parse( + lexicon_json.clone(), + revision, + None, + ProcedureAction::Upsert, + None, + ) + .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) + .unwrap_or_else(|_| "unknown".into()); + + let has_script = script.is_some(); + Ok(Json(serde_json::json!({ "id": id, "revision": revision, "lexicon_json": lexicon_json, + "lexicon_type": lexicon_type, "backfill": backfill, "action": action, + "target_collection": target_collection, + "has_script": has_script, + "script": script, "source": source, "authority_did": authority_did, "last_fetched_at": last_fetched_at, diff --git a/src/admin/network_lexicons.rs b/src/admin/network_lexicons.rs --- a/src/admin/network_lexicons.rs +++ b/src/admin/network_lexicons.rs @@ -40,6 +40,7 @@ lexicon_json.clone(), 1, body.target_collection.clone(), ProcedureAction::Upsert, + None, ) .map_err(|e| AppError::BadRequest(format!("failed to parse lexicon: {e}")))?; @@ -76,6 +77,7 @@ lexicon_json, revision, body.target_collection, ProcedureAction::Upsert, + None, ) .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; state.lexicons.upsert(parsed).await; diff --git a/src/admin/types.rs b/src/admin/types.rs --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -13,11 +13,15 @@ pub(super) lexicon_type: String, pub(super) backfill: bool, pub(super) action: Option, pub(super) target_collection: Option, + pub(super) has_script: bool, pub(super) source: String, pub(super) authority_did: Option, pub(super) last_fetched_at: Option>, pub(super) created_at: chrono::DateTime, pub(super) updated_at: chrono::DateTime, + /// For record-type lexicons: the `properties` object from `defs.main.record`. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) record_schema: Option, } #[derive(Deserialize)] @@ -27,6 +31,7 @@ #[serde(default = "default_backfill")] pub(super) backfill: bool, pub(super) target_collection: Option, pub(super) action: Option, + pub(super) script: Option, } fn default_backfill() -> bool { diff --git a/src/lexicon.rs b/src/lexicon.rs --- a/src/lexicon.rs +++ b/src/lexicon.rs @@ -79,6 +79,8 @@ /// For queries/procedures: the backing record collection NSID. pub target_collection: Option, /// For procedures: the action this procedure performs (create, update, delete, upsert). pub action: ProcedureAction, + /// Optional Lua script that replaces the built-in handler. + pub script: Option, } impl ParsedLexicon { @@ -88,6 +90,7 @@ raw: Value, revision: i32, target_collection: Option, action: ProcedureAction, + script: Option, ) -> Result { let id = raw .get("id") @@ -130,6 +133,7 @@ raw, revision, target_collection, action, + script, }) } } @@ -156,8 +160,15 @@ /// Load all lexicons from the database, replacing any existing entries. pub async fn load_from_db(&self, db: &sqlx::PgPool) -> Result<(), String> { #[allow(clippy::type_complexity)] - let rows: Vec<(String, Value, i32, Option, Option)> = sqlx::query_as( - "SELECT id, lexicon_json, revision, target_collection, action FROM lexicons", + let rows: Vec<( + String, + Value, + i32, + Option, + Option, + Option, + )> = sqlx::query_as( + "SELECT id, lexicon_json, revision, target_collection, action, script FROM lexicons", ) .fetch_all(db) .await @@ -167,7 +178,7 @@ let mut inner = self.inner.write().await; inner.clear(); let mut loaded = 0u32; - for (id, json, revision, target_collection, action_str) in rows { + for (id, json, revision, target_collection, action_str, script) in rows { let action = match ProcedureAction::from_optional_str(action_str.as_deref()) { Ok(a) => a, Err(e) => { @@ -175,7 +186,7 @@ warn!(%id, "invalid action value: {e}"); ProcedureAction::Upsert } }; - match ParsedLexicon::parse(json, revision, target_collection, action) { + match ParsedLexicon::parse(json, revision, target_collection, action, script) { Ok(parsed) => { inner.insert(id, parsed); loaded += 1; @@ -327,8 +338,14 @@ } #[test] fn parse_record_lexicon() { - let parsed = - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); assert_eq!(parsed.id, "games.gamesgamesgamesgames.game"); assert_eq!(parsed.lexicon_type, LexiconType::Record); assert_eq!(parsed.record_key, Some("tid".into())); @@ -344,6 +361,7 @@ query_lexicon_json(), 2, Some("games.gamesgamesgamesgames.game".into()), ProcedureAction::Upsert, + None, ) .unwrap(); assert_eq!(parsed.lexicon_type, LexiconType::Query); @@ -358,9 +376,14 @@ } #[test] fn parse_procedure_lexicon() { - let parsed = - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Upsert) - .unwrap(); + let parsed = ParsedLexicon::parse( + procedure_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); assert_eq!(parsed.lexicon_type, LexiconType::Procedure); assert!(parsed.input.is_some()); assert!(parsed.output.is_some()); @@ -368,24 +391,34 @@ } #[test] fn parse_procedure_with_action() { - let parsed = - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Delete) - .unwrap(); + let parsed = ParsedLexicon::parse( + procedure_lexicon_json(), + 1, + None, + ProcedureAction::Delete, + None, + ) + .unwrap(); assert_eq!(parsed.action, ProcedureAction::Delete); } #[test] fn parse_definitions_lexicon() { - let parsed = - ParsedLexicon::parse(definitions_lexicon_json(), 1, None, ProcedureAction::Upsert) - .unwrap(); + let parsed = ParsedLexicon::parse( + definitions_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); assert_eq!(parsed.lexicon_type, LexiconType::Definitions); } #[test] fn parse_missing_id_returns_error() { let raw = json!({"lexicon": 1, "defs": {}}); - let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("id")); } @@ -393,7 +426,8 @@ #[test] fn parse_preserves_raw_json() { let raw = record_lexicon_json(); - let parsed = ParsedLexicon::parse(raw.clone(), 1, None, ProcedureAction::Upsert).unwrap(); + let parsed = + ParsedLexicon::parse(raw.clone(), 1, None, ProcedureAction::Upsert, None).unwrap(); assert_eq!(parsed.raw, raw); } @@ -404,6 +438,7 @@ query_lexicon_json(), 1, Some("custom.collection".into()), ProcedureAction::Upsert, + None, ) .unwrap(); assert_eq!(parsed.target_collection, Some("custom.collection".into())); @@ -422,8 +457,14 @@ #[tokio::test] async fn registry_upsert_and_get() { let reg = LexiconRegistry::new(); - let parsed = - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); reg.upsert(parsed).await; let got = reg.get("games.gamesgamesgamesgames.game").await; @@ -434,12 +475,24 @@ #[tokio::test] async fn registry_upsert_replaces() { let reg = LexiconRegistry::new(); - let v1 = - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); + let v1 = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); reg.upsert(v1).await; - let v2 = - ParsedLexicon::parse(record_lexicon_json(), 5, None, ProcedureAction::Upsert).unwrap(); + let v2 = ParsedLexicon::parse( + record_lexicon_json(), + 5, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); reg.upsert(v2).await; assert_eq!(reg.count().await, 1); @@ -455,8 +508,14 @@ #[tokio::test] async fn registry_remove_existing() { let reg = LexiconRegistry::new(); - let parsed = - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); reg.upsert(parsed).await; assert!(reg.remove("games.gamesgamesgamesgames.game").await); @@ -479,16 +538,33 @@ #[tokio::test] async fn registry_type_filtered_collections() { let reg = LexiconRegistry::new(); - let record = - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); + let record = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); let query = - ParsedLexicon::parse(query_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); - let procedure = - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Upsert) + ParsedLexicon::parse(query_lexicon_json(), 1, None, ProcedureAction::Upsert, None) .unwrap(); - let defs = - ParsedLexicon::parse(definitions_lexicon_json(), 1, None, ProcedureAction::Upsert) - .unwrap(); + let procedure = ParsedLexicon::parse( + procedure_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + let defs = ParsedLexicon::parse( + definitions_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); reg.upsert(record).await; reg.upsert(query).await; diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod config; pub mod error; pub mod lexicon; +pub mod lua; pub mod profile; pub mod repo; pub mod resolve; diff --git a/src/lua/context.rs b/src/lua/context.rs new file mode 100644 --- /dev/null +++ b/src/lua/context.rs @@ -0,0 +1,33 @@ +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use serde_json::Value; +use std::collections::HashMap; + +/// Set global context variables for a procedure script. +pub fn set_procedure_context( + lua: &Lua, + method: &str, + input: &Value, + caller_did: &str, + collection: &str, +) -> LuaResult<()> { + let globals = lua.globals(); + globals.set("method", method.to_string())?; + globals.set("input", lua.to_value(input)?)?; + globals.set("caller_did", caller_did.to_string())?; + globals.set("collection", collection.to_string())?; + Ok(()) +} + +/// Set global context variables for a query script. +pub fn set_query_context( + lua: &Lua, + method: &str, + params: &HashMap, + collection: &str, +) -> LuaResult<()> { + let globals = lua.globals(); + globals.set("method", method.to_string())?; + globals.set("params", lua.to_value(params)?)?; + globals.set("collection", collection.to_string())?; + Ok(()) +} diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs new file mode 100644 --- /dev/null +++ b/src/lua/db_api.rs @@ -0,0 +1,119 @@ +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use serde_json::{Value, json}; +use std::sync::Arc; + +use crate::AppState; + +/// Register the `db` table with read-only database query functions. +pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { + let db_table = lua.create_table()?; + + // db.query({ collection, did?, limit?, offset? }) -> { records, cursor? } + let state_query = state.clone(); + let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { + let state = state_query.clone(); + async move { + let collection: String = opts.get("collection")?; + let did: Option = opts.get("did").ok(); + let limit: i64 = opts.get::("limit").unwrap_or(20).min(100); + let offset: i64 = opts.get::("offset").unwrap_or(0); + + let rows: Vec<(String, String, Value)> = if let Some(ref did) = did { + sqlx::query_as( + "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 ORDER BY indexed_at DESC LIMIT $3 OFFSET $4", + ) + .bind(&collection) + .bind(did) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? + } else { + sqlx::query_as( + "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3", + ) + .bind(&collection) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? + }; + + let has_next = rows.len() as i64 == limit; + let records: Vec = rows + .into_iter() + .map(|(uri, _did, mut record)| { + if let Some(obj) = record.as_object_mut() { + obj.insert("uri".to_string(), json!(uri)); + } + record + }) + .collect(); + + let mut result = json!({ "records": records }); + if has_next { + let next_cursor = (offset + limit).to_string(); + result.as_object_mut().unwrap().insert("cursor".to_string(), json!(next_cursor)); + } + + lua.to_value(&result) + } + })?; + db_table.set("query", query_fn)?; + + // db.get(uri) -> record table or nil + let state_get = state.clone(); + let get_fn = lua.create_async_function(move |lua, uri: String| { + let state = state_get.clone(); + async move { + let row: Option<(Value,)> = sqlx::query_as("SELECT record FROM records WHERE uri = $1") + .bind(&uri) + .fetch_optional(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; + + match row { + Some((mut record,)) => { + if let Some(obj) = record.as_object_mut() { + obj.insert("uri".to_string(), json!(uri)); + } + lua.to_value(&record) + } + None => Ok(mlua::Value::Nil), + } + } + })?; + db_table.set("get", get_fn)?; + + // db.count(collection, did?) -> integer + let state_count = state; + let count_fn = + lua.create_async_function(move |_, (collection, did): (String, Option)| { + let state = state_count.clone(); + async move { + let count: (i64,) = if let Some(ref did) = did { + sqlx::query_as( + "SELECT COUNT(*) FROM records WHERE collection = $1 AND did = $2", + ) + .bind(&collection) + .bind(did) + .fetch_one(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? + } else { + sqlx::query_as("SELECT COUNT(*) FROM records WHERE collection = $1") + .bind(&collection) + .fetch_one(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? + }; + Ok(count.0) + } + })?; + db_table.set("count", count_fn)?; + + lua.globals().set("db", db_table)?; + Ok(()) +} diff --git a/src/lua/execute.rs b/src/lua/execute.rs new file mode 100644 --- /dev/null +++ b/src/lua/execute.rs @@ -0,0 +1,123 @@ +use axum::Json; +use axum::response::{IntoResponse, Response}; +use mlua::LuaSerdeExt; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::AppState; +use crate::auth::Claims; +use crate::error::AppError; +use crate::lexicon::ParsedLexicon; +use crate::repo; + +use super::context; +use super::db_api; +use super::record; +use super::sandbox; + +/// Execute a Lua script for a procedure endpoint. +pub async fn execute_procedure_script( + state: &AppState, + method: &str, + claims: &Claims, + input: &Value, + lexicon: &ParsedLexicon, + script: &str, +) -> Result { + let collection = lexicon.target_collection.as_deref().unwrap_or_default(); + + let session = repo::get_atp_session(state, claims.token()).await?; + + let lua = sandbox::create_sandbox() + .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?; + + let state_arc = Arc::new(state.clone()); + let claims_arc = Arc::new(claims.clone()); + let session_arc = Arc::new(session); + + db_api::register_db_api(&lua, state_arc.clone()) + .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?; + + record::register_record_api(&lua, state_arc, claims_arc, session_arc) + .map_err(|e| AppError::Internal(format!("failed to register Record API: {e}")))?; + + context::set_procedure_context(&lua, method, input, claims.did(), collection) + .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?; + + lua.load(script).exec().map_err(|e| { + tracing::error!(method, error = %e, "lua script load failed"); + AppError::Internal("script execution failed".into()) + })?; + + let handle: mlua::Function = lua.globals().get("handle").map_err(|e| { + tracing::error!(method, error = %e, "lua script missing handle function"); + AppError::Internal("script execution failed".into()) + })?; + + let result: mlua::Value = handle.call_async(()).await.map_err(|e| { + let msg = e.to_string(); + tracing::error!(method, error = %msg, "lua script execution failed"); + if msg.contains("execution limit") { + AppError::Internal("script exceeded execution time limit".into()) + } else { + AppError::Internal("script execution failed".into()) + } + })?; + + let json_value: Value = lua.from_value(result).map_err(|e| { + tracing::error!(method, error = %e, "failed to convert lua result to JSON"); + AppError::Internal("script execution failed".into()) + })?; + + Ok(Json(json_value).into_response()) +} + +/// Execute a Lua script for a query endpoint. +pub async fn execute_query_script( + state: &AppState, + method: &str, + params: &HashMap, + lexicon: &ParsedLexicon, + script: &str, +) -> Result { + let collection = lexicon.target_collection.as_deref().unwrap_or_default(); + + let lua = sandbox::create_sandbox() + .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?; + + let state_arc = Arc::new(state.clone()); + + db_api::register_db_api(&lua, state_arc) + .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?; + + context::set_query_context(&lua, method, params, collection) + .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?; + + lua.load(script).exec().map_err(|e| { + tracing::error!(method, error = %e, "lua script load failed"); + AppError::Internal("script execution failed".into()) + })?; + + let handle: mlua::Function = lua.globals().get("handle").map_err(|e| { + tracing::error!(method, error = %e, "lua script missing handle function"); + AppError::Internal("script execution failed".into()) + })?; + + let result: mlua::Value = handle.call_async(()).await.map_err(|e| { + let msg = e.to_string(); + tracing::error!(method, error = %msg, "lua script execution failed"); + if msg.contains("execution limit") { + AppError::Internal("script exceeded execution time limit".into()) + } else { + AppError::Internal("script execution failed".into()) + } + })?; + + let json_value: Value = lua.from_value(result).map_err(|e| { + tracing::error!(method, error = %e, "failed to convert lua result to JSON"); + AppError::Internal("script execution failed".into()) + })?; + + Ok(Json(json_value).into_response()) +} diff --git a/src/lua/mod.rs b/src/lua/mod.rs new file mode 100644 --- /dev/null +++ b/src/lua/mod.rs @@ -0,0 +1,9 @@ +mod context; +mod db_api; +mod execute; +mod record; +pub(crate) mod sandbox; +mod tid; + +pub(crate) use execute::{execute_procedure_script, execute_query_script}; +pub(crate) use sandbox::validate_script; diff --git a/src/lua/record.rs b/src/lua/record.rs new file mode 100644 --- /dev/null +++ b/src/lua/record.rs @@ -0,0 +1,840 @@ +use futures_util::future::try_join_all; +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use serde_json::{Value, json}; +use std::sync::Arc; + +use crate::AppState; +use crate::auth::Claims; +use crate::repo::{self, AtpSession}; + +use super::tid::generate_tid; + +const INTERNAL_FIELDS: &[&str] = &[ + "_collection", + "_uri", + "_cid", + "_schema", + "_key_type", + "_rkey", +]; + +/// Register the `Record` global constructor and static methods. +/// Only registered for procedure scripts (not queries). +pub fn register_record_api( + lua: &Lua, + state: Arc, + claims: Arc, + session: Arc, +) -> LuaResult<()> { + // -- methods table (shared by all Record instances) -- + let methods = lua.create_table()?; + + // :save() + { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + async move { + let collection: String = this.raw_get("_collection")?; + let schema: mlua::Value = this.raw_get("_schema")?; + + // Validate required fields against schema + if let mlua::Value::Table(ref schema_table) = schema { + validate_required_fields(&this, schema_table)?; + } + + // Serialize record data (skip _ keys, inject $type) + let data = extract_record_data(&lua, &this, &collection)?; + + let existing_uri: Option = this.raw_get("_uri")?; + + let pds_result = if let Some(ref uri) = existing_uri { + // PUT + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? + .to_string(); + + let pds_body = json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + "record": data, + }); + + let resp = repo::pds_post_json_raw( + &state, + &session, + "com.atproto.repo.putRecord", + &pds_body, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(mlua::Error::runtime(format!( + "PDS putRecord returned {status}: {body}" + ))); + } + + let bytes = resp.bytes().await.map_err(|e| { + mlua::Error::runtime(format!("failed to read PDS response: {e}")) + })?; + let result: Value = serde_json::from_slice(&bytes) + .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?; + + // Upsert local DB + let cid = result + .get("cid") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let _ = sqlx::query( + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (uri) DO UPDATE + SET record = EXCLUDED.record, + cid = EXCLUDED.cid, + indexed_at = NOW()"#, + ) + .bind(uri) + .bind(claims.did()) + .bind(&collection) + .bind(&rkey) + .bind(&data) + .bind(cid) + .execute(&state.db) + .await; + + result + } else { + // CREATE + let rkey: Option = this.raw_get("_rkey")?; + let mut pds_body = json!({ + "repo": claims.did(), + "collection": collection, + "record": data, + }); + if let Some(ref rkey) = rkey { + pds_body["rkey"] = json!(rkey); + } + + let resp = repo::pds_post_json_raw( + &state, + &session, + "com.atproto.repo.createRecord", + &pds_body, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(mlua::Error::runtime(format!( + "PDS createRecord returned {status}: {body}" + ))); + } + + let bytes = resp.bytes().await.map_err(|e| { + mlua::Error::runtime(format!("failed to read PDS response: {e}")) + })?; + let result: Value = serde_json::from_slice(&bytes) + .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?; + + // Upsert local DB + if let (Some(uri), Some(cid)) = ( + result.get("uri").and_then(|v| v.as_str()), + result.get("cid").and_then(|v| v.as_str()), + ) { + let rkey = uri.split('/').next_back().unwrap_or_default(); + let _ = sqlx::query( + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (uri) DO UPDATE + SET record = EXCLUDED.record, + cid = EXCLUDED.cid"#, + ) + .bind(uri) + .bind(claims.did()) + .bind(&collection) + .bind(rkey) + .bind(&data) + .bind(cid) + .execute(&state.db) + .await; + } + + result + }; + + // Write back _uri and _cid + if let Some(uri) = pds_result.get("uri").and_then(|v| v.as_str()) { + this.raw_set("_uri", uri.to_string())?; + } + if let Some(cid) = pds_result.get("cid").and_then(|v| v.as_str()) { + this.raw_set("_cid", cid.to_string())?; + } + + Ok(this) + } + })?; + methods.set("save", save_fn)?; + } + + // :delete() + { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + async move { + let uri: String = this.raw_get::>("_uri")?.ok_or_else(|| { + mlua::Error::runtime("cannot delete a Record that has no _uri") + })?; + let collection: String = this.raw_get("_collection")?; + + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? + .to_string(); + + let pds_body = json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + }); + + let resp = repo::pds_post_json_raw( + &state, + &session, + "com.atproto.repo.deleteRecord", + &pds_body, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(mlua::Error::runtime(format!( + "PDS deleteRecord returned {status}: {body}" + ))); + } + + // Delete from local DB + let _ = sqlx::query("DELETE FROM records WHERE uri = $1") + .bind(&uri) + .execute(&state.db) + .await; + + // Clear _uri and _cid + this.raw_set("_uri", mlua::Value::Nil)?; + this.raw_set("_cid", mlua::Value::Nil)?; + + Ok(this) + } + })?; + methods.set("delete", delete_fn)?; + } + + // :set_key_type(type) + { + let set_key_type_fn = + lua.create_function(|_lua, (this, key_type): (mlua::Table, String)| { + match key_type.as_str() { + "tid" | "any" | "nsid" => {} + s if s.starts_with("literal:") && s.len() > "literal:".len() => {} + _ => { + return Err(mlua::Error::runtime(format!( + "invalid key type '{key_type}': expected tid, any, nsid, or literal:*" + ))); + } + } + this.raw_set("_key_type", key_type)?; + Ok(this) + })?; + methods.set("set_key_type", set_key_type_fn)?; + } + + // :set_rkey(key) + { + let set_rkey_fn = lua.create_function(|_lua, (this, key): (mlua::Table, String)| { + if key.is_empty() { + return Err(mlua::Error::runtime("rkey must be a non-empty string")); + } + this.raw_set("_rkey", key)?; + Ok(this) + })?; + methods.set("set_rkey", set_rkey_fn)?; + } + + // :generate_rkey() + { + let generate_rkey_fn = lua.create_function(|_lua, this: mlua::Table| { + let key_type: Option = this.raw_get("_key_type")?; + let rkey = match key_type.as_deref() { + Some("tid") | Some("any") => generate_tid(), + Some(s) if s.starts_with("literal:") => s["literal:".len()..].to_string(), + Some("nsid") => { + return Err(mlua::Error::runtime( + "cannot auto-generate rkey for nsid key type — use set_rkey() instead", + )); + } + Some(other) => { + return Err(mlua::Error::runtime(format!("unknown key type '{other}'"))); + } + None => { + return Err(mlua::Error::runtime( + "no _key_type set — call set_key_type() first or use a record-type lexicon", + )); + } + }; + this.raw_set("_rkey", rkey.as_str())?; + Ok(rkey) + })?; + methods.set("generate_rkey", generate_rkey_fn)?; + } + + // -- metatable -- + let metatable = lua.create_table()?; + + // __index: check methods first, then rawget + { + let methods_ref = methods.clone(); + let index_fn = lua.create_function(move |_lua, (this, key): (mlua::Table, String)| { + // Check methods table first + let method: mlua::Value = methods_ref.raw_get(key.as_str())?; + if !method.is_nil() { + return Ok(method); + } + // Fall through to raw field access + this.raw_get::(key.as_str()) + })?; + metatable.set("__index", index_fn)?; + } + + // __newindex: block writes to internal fields + { + let newindex_fn = lua.create_function( + move |_lua, (this, key, value): (mlua::Table, String, mlua::Value)| { + if INTERNAL_FIELDS.contains(&key.as_str()) { + return Err(mlua::Error::runtime(format!( + "cannot assign to internal field '{key}'" + ))); + } + this.raw_set(key, value)?; + Ok(()) + }, + )?; + metatable.set("__newindex", newindex_fn)?; + } + + // __tostring + { + let tostring_fn = lua.create_function(|_lua, this: mlua::Table| { + let collection: String = this.raw_get("_collection")?; + let uri: Option = this.raw_get("_uri")?; + match uri { + Some(u) => Ok(format!("Record({collection}) [uri={u}]")), + None => Ok(format!("Record({collection}) [unsaved]")), + } + })?; + metatable.set("__tostring", tostring_fn)?; + } + + // -- Record constructor function -- + let record_table = lua.create_table()?; + + { + let state_c = state.clone(); + let metatable_c = metatable.clone(); + let constructor = lua.create_async_function( + move |lua, (collection, data): (String, Option)| { + let state = state_c.clone(); + let metatable = metatable_c.clone(); + async move { + let table = lua.create_table()?; + + // Look up schema + let lexicon = state.lexicons.get(&collection).await; + let schema_value: mlua::Value = + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { + Some(schema_json) => lua.to_value(schema_json)?, + None => mlua::Value::Nil, + }; + + // Set internal fields + table.raw_set("_collection", collection.as_str())?; + table.raw_set("_uri", mlua::Value::Nil)?; + table.raw_set("_cid", mlua::Value::Nil)?; + table.raw_set("_schema", schema_value.clone())?; + + // Auto-set _key_type from the lexicon's record_key + match lexicon.as_ref().and_then(|l| l.record_key.as_deref()) { + Some(key) => table.raw_set("_key_type", key)?, + None => table.raw_set("_key_type", mlua::Value::Nil)?, + } + table.raw_set("_rkey", mlua::Value::Nil)?; + + // Copy fields from data if provided + if let Some(mlua::Value::Table(data_table)) = data { + for pair in data_table.pairs::() { + let (k, v) = pair?; + table.raw_set(k, v)?; + } + } + + // Populate defaults from schema + if let mlua::Value::Table(ref schema_table) = schema_value { + populate_defaults(&lua, &table, schema_table)?; + } + + table.set_metatable(Some(metatable))?; + Ok(table) + } + }, + )?; + record_table.set("new", constructor)?; + } + + // -- Static methods -- + + // Record.save_all(records) + { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + let save_all_fn = + lua.create_async_function(move |lua, records_table: mlua::Table| { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + async move { + // Extract save data from each record (sync) + type SaveItem = (mlua::Table, String, Option, Option, Value); + let mut save_items: Vec = Vec::new(); + + for pair in records_table.sequence_values::() { + let record_table = pair?; + let collection: String = record_table.raw_get("_collection")?; + let existing_uri: Option = record_table.raw_get("_uri")?; + let rkey: Option = record_table.raw_get("_rkey")?; + + // Validate + let schema: mlua::Value = record_table.raw_get("_schema")?; + if let mlua::Value::Table(ref schema_table) = schema { + validate_required_fields(&record_table, schema_table)?; + } + + let data = extract_record_data(&lua, &record_table, &collection)?; + save_items.push((record_table, collection, existing_uri, rkey, data)); + } + + // Parallel PDS calls + let futs = save_items.iter().map(|(_, collection, existing_uri, rkey, data)| { + let state = state.clone(); + let claims = claims.clone(); + let session = session.clone(); + let collection = collection.clone(); + let existing_uri = existing_uri.clone(); + let rkey = rkey.clone(); + let data = data.clone(); + async move { + if let Some(ref uri) = existing_uri { + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? + .to_string(); + + let pds_body = json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + "record": data, + }); + + let resp = repo::pds_post_json_raw( + &state, + &session, + "com.atproto.repo.putRecord", + &pds_body, + ) + .await + .map_err(|e| { + mlua::Error::runtime(format!("PDS putRecord failed: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(mlua::Error::runtime(format!( + "PDS putRecord returned {status}: {body}" + ))); + } + + let bytes = resp.bytes().await.map_err(|e| { + mlua::Error::runtime(format!( + "failed to read PDS response: {e}" + )) + })?; + let result: Value = serde_json::from_slice(&bytes).map_err( + |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")), + )?; + + let cid = result + .get("cid") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let _ = sqlx::query( + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (uri) DO UPDATE + SET record = EXCLUDED.record, + cid = EXCLUDED.cid, + indexed_at = NOW()"#, + ) + .bind(uri.as_str()) + .bind(claims.did()) + .bind(&collection) + .bind(&rkey) + .bind(&data) + .bind(cid) + .execute(&state.db) + .await; + + Ok(result) + } else { + let mut pds_body = json!({ + "repo": claims.did(), + "collection": collection, + "record": data, + }); + if let Some(ref rkey) = rkey { + pds_body["rkey"] = json!(rkey); + } + + let resp = repo::pds_post_json_raw( + &state, + &session, + "com.atproto.repo.createRecord", + &pds_body, + ) + .await + .map_err(|e| { + mlua::Error::runtime(format!( + "PDS createRecord failed: {e}" + )) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(mlua::Error::runtime(format!( + "PDS createRecord returned {status}: {body}" + ))); + } + + let bytes = resp.bytes().await.map_err(|e| { + mlua::Error::runtime(format!( + "failed to read PDS response: {e}" + )) + })?; + let result: Value = serde_json::from_slice(&bytes).map_err( + |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")), + )?; + + if let (Some(uri), Some(cid)) = ( + result.get("uri").and_then(|v| v.as_str()), + result.get("cid").and_then(|v| v.as_str()), + ) { + let rkey = + uri.split('/').next_back().unwrap_or_default(); + let _ = sqlx::query( + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (uri) DO UPDATE + SET record = EXCLUDED.record, + cid = EXCLUDED.cid"#, + ) + .bind(uri) + .bind(claims.did()) + .bind(&collection) + .bind(rkey) + .bind(&data) + .bind(cid) + .execute(&state.db) + .await; + } + + Ok(result) + } + } + }); + + let results = try_join_all(futs).await?; + + // Write back _uri and _cid (sync) + for (i, (record_table, _, _, _, _)) in save_items.iter().enumerate() { + if let Some(result) = results.get(i) { + if let Some(uri) = result.get("uri").and_then(|v| v.as_str()) { + record_table.raw_set("_uri", uri.to_string())?; + } + if let Some(cid) = result.get("cid").and_then(|v| v.as_str()) { + record_table.raw_set("_cid", cid.to_string())?; + } + } + } + + lua.to_value(&results) + } + })?; + record_table.set("save_all", save_all_fn)?; + } + + // Record.load(uri) + { + let state = state.clone(); + let metatable_c = metatable.clone(); + let load_fn = lua.create_async_function(move |lua, uri: String| { + let state = state.clone(); + let metatable = metatable_c.clone(); + async move { + let row: Option<(String, Value, String)> = + sqlx::query_as("SELECT collection, record, cid FROM records WHERE uri = $1") + .bind(&uri) + .fetch_optional(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; + + match row { + Some((collection, record, cid)) => { + let table = lua.create_table()?; + + // Look up schema + let lexicon = state.lexicons.get(&collection).await; + let schema_value: mlua::Value = + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { + Some(schema_json) => lua.to_value(schema_json)?, + None => mlua::Value::Nil, + }; + + table.raw_set("_collection", collection.as_str())?; + table.raw_set("_uri", uri.as_str())?; + table.raw_set("_cid", cid.as_str())?; + table.raw_set("_schema", schema_value)?; + table.raw_set("_key_type", mlua::Value::Nil)?; + table.raw_set("_rkey", mlua::Value::Nil)?; + + // Copy record fields + if let Some(obj) = record.as_object() { + for (k, v) in obj { + if k == "$type" { + continue; + } + let lua_val: mlua::Value = lua.to_value(v)?; + table.raw_set(k.as_str(), lua_val)?; + } + } + + table.set_metatable(Some(metatable))?; + Ok(mlua::Value::Table(table)) + } + None => Ok(mlua::Value::Nil), + } + } + })?; + record_table.set("load", load_fn)?; + } + + // Record.load_all(uris) + { + let state = state; + let metatable_c = metatable; + let load_all_fn = lua.create_async_function(move |lua, uris_table: mlua::Table| { + let state = state.clone(); + let metatable = metatable_c.clone(); + async move { + let uris: Vec = lua.from_value(mlua::Value::Table(uris_table))?; + + let futs = uris.iter().map(|uri| { + let state = state.clone(); + let uri = uri.clone(); + async move { + let row: Option<(String, Value, String)> = sqlx::query_as( + "SELECT collection, record, cid FROM records WHERE uri = $1", + ) + .bind(&uri) + .fetch_optional(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; + + let result: Result<_, mlua::Error> = + Ok(row.map(|(collection, record, cid)| (uri, collection, record, cid))); + result + } + }); + + let results: Vec> = + try_join_all(futs).await?; + + let out = lua.create_table()?; + for (i, item) in results.into_iter().enumerate() { + match item { + Some((uri, collection, record, cid)) => { + let table = lua.create_table()?; + + let lexicon = state.lexicons.get(&collection).await; + let schema_value: mlua::Value = + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { + Some(schema_json) => lua.to_value(schema_json)?, + None => mlua::Value::Nil, + }; + + table.raw_set("_collection", collection.as_str())?; + table.raw_set("_uri", uri.as_str())?; + table.raw_set("_cid", cid.as_str())?; + table.raw_set("_schema", schema_value)?; + table.raw_set("_key_type", mlua::Value::Nil)?; + table.raw_set("_rkey", mlua::Value::Nil)?; + + if let Some(obj) = record.as_object() { + for (k, v) in obj { + if k == "$type" { + continue; + } + let lua_val: mlua::Value = lua.to_value(v)?; + table.raw_set(k.as_str(), lua_val)?; + } + } + + table.set_metatable(Some(metatable.clone()))?; + out.raw_set(i + 1, table)?; + } + None => { + out.raw_set(i + 1, mlua::Value::Nil)?; + } + } + } + + Ok(mlua::Value::Table(out)) + } + })?; + record_table.set("load_all", load_all_fn)?; + } + + // -- Make Record callable via __call metamethod -- + let record_mt = lua.create_table()?; + { + let new_fn: mlua::Function = record_table.get("new")?; + let call_fn = + lua.create_async_function( + move |_lua, + (_self_table, collection, data): ( + mlua::Table, + String, + Option, + )| { + let new_fn = new_fn.clone(); + async move { + let result: mlua::Table = new_fn.call_async((collection, data)).await?; + Ok(result) + } + }, + )?; + record_mt.set("__call", call_fn)?; + } + record_table.set_metatable(Some(record_mt))?; + + lua.globals().set("Record", record_table)?; + Ok(()) +} + +/// Check that all required fields (per schema) are present and non-nil. +fn validate_required_fields(table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> { + let required: Option = schema.raw_get("required")?; + if let Some(required) = required { + for pair in required.sequence_values::() { + let field = pair?; + let val: mlua::Value = table.raw_get(field.as_str())?; + if val.is_nil() { + return Err(mlua::Error::runtime(format!( + "missing required field '{field}'" + ))); + } + } + } + Ok(()) +} + +/// Set missing fields from schema property defaults. +fn populate_defaults(lua: &Lua, table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> { + let properties: Option = schema.raw_get("properties")?; + if let Some(properties) = properties { + for pair in properties.pairs::() { + let (key, prop_def) = pair?; + // Skip internal fields + if key.starts_with('_') { + continue; + } + let existing: mlua::Value = table.raw_get(key.as_str())?; + if existing.is_nil() { + let default: mlua::Value = prop_def.raw_get("default")?; + if !default.is_nil() { + table.raw_set(key.as_str(), lua.to_value(&default)?)?; + } + } + } + } + Ok(()) +} + +/// Serialize a Record table to serde_json::Value, stripping _-prefixed keys, +/// filtering to only schema-defined properties, and injecting $type. +fn extract_record_data(lua: &Lua, table: &mlua::Table, collection: &str) -> LuaResult { + // Build the set of allowed property names from the schema (if available). + // When a schema is present, only fields listed in `properties` are included. + let schema: mlua::Value = table.raw_get("_schema")?; + let allowed: Option> = if let mlua::Value::Table(ref schema_table) = schema { + let properties: Option = schema_table.raw_get("properties")?; + properties.map(|props| { + props + .pairs::() + .filter_map(|pair| pair.ok().map(|(k, _)| k)) + .collect() + }) + } else { + None + }; + + let tmp = lua.create_table()?; + for pair in table.pairs::() { + let (k, v) = pair?; + if k.starts_with('_') { + continue; + } + if let Some(ref keys) = allowed + && !keys.iter().any(|a| a == &k) + { + continue; + } + tmp.raw_set(k, v)?; + } + + let mut data: Value = lua.from_value(mlua::Value::Table(tmp))?; + if let Some(obj) = data.as_object_mut() { + obj.insert("$type".to_string(), json!(collection)); + } + Ok(data) +} diff --git a/src/lua/sandbox.rs b/src/lua/sandbox.rs new file mode 100644 --- /dev/null +++ b/src/lua/sandbox.rs @@ -0,0 +1,146 @@ +use mlua::{Lua, Result as LuaResult}; + +use super::tid::generate_tid; + +const INSTRUCTION_LIMIT: u32 = 1_000_000; + +/// Create a fresh sandboxed Lua VM. +/// +/// - Dangerous globals (`os`, `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`) are removed. +/// - An instruction-count hook prevents infinite loops. +/// - Utility globals `now()` and `log()` are injected. +pub fn create_sandbox() -> LuaResult { + let lua = Lua::new(); + + // Remove dangerous globals + let globals = lua.globals(); + for name in &[ + "os", + "io", + "debug", + "package", + "require", + "dofile", + "loadfile", + "load", + "collectgarbage", + ] { + globals.raw_set(*name, mlua::Value::Nil)?; + } + + // Instruction limit to prevent infinite loops + lua.set_hook( + mlua::HookTriggers::new().every_nth_instruction(INSTRUCTION_LIMIT), + |_lua, _debug| Err(mlua::Error::runtime("script exceeded execution limit")), + )?; + + // Utility: now() returns UTC ISO 8601 string + let now_fn = lua.create_function(|_, ()| Ok(chrono::Utc::now().to_rfc3339()))?; + globals.set("now", now_fn)?; + + // Utility: log(message) logs via tracing::debug + let log_fn = lua.create_function(|_, msg: String| { + tracing::debug!(lua_log = %msg, "lua script log"); + Ok(()) + })?; + globals.set("log", log_fn)?; + + // Utility: TID() returns a fresh AT Protocol TID string + let tid_fn = lua.create_function(|_, ()| Ok(generate_tid()))?; + globals.set("TID", tid_fn)?; + + Ok(lua) +} + +/// Validate that a script compiles and defines a `handle` function. +pub fn validate_script(source: &str) -> Result<(), String> { + let lua = create_sandbox().map_err(|e| format!("failed to create Lua VM: {e}"))?; + lua.load(source) + .exec() + .map_err(|e| format!("script compilation failed: {e}"))?; + + let globals = lua.globals(); + match globals.get::("handle") { + Ok(_) => Ok(()), + Err(_) => Err("script must define a handle() function".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_removes_dangerous_globals() { + let lua = create_sandbox().unwrap(); + let globals = lua.globals(); + assert!(globals.get::("os").unwrap().is_nil()); + assert!(globals.get::("io").unwrap().is_nil()); + assert!(globals.get::("debug").unwrap().is_nil()); + assert!(globals.get::("package").unwrap().is_nil()); + assert!(globals.get::("require").unwrap().is_nil()); + } + + #[test] + fn sandbox_provides_now() { + let lua = create_sandbox().unwrap(); + let result: String = lua.load("return now()").eval().unwrap(); + assert!(result.contains("T")); // ISO 8601 format + } + + #[test] + fn sandbox_provides_log() { + let lua = create_sandbox().unwrap(); + lua.load(r#"log("test message")"#).exec().unwrap(); + } + + #[test] + fn sandbox_provides_tid() { + let lua = create_sandbox().unwrap(); + let result: String = lua.load("return TID()").eval().unwrap(); + assert_eq!(result.len(), 13); + let valid = "234567abcdefghijklmnopqrstuvwxyz"; + for ch in result.chars() { + assert!(valid.contains(ch), "invalid char '{ch}' in TID"); + } + } + + #[test] + fn sandbox_tid_returns_unique_values() { + let lua = create_sandbox().unwrap(); + let a: String = lua.load("return TID()").eval().unwrap(); + let b: String = lua.load("return TID()").eval().unwrap(); + assert_ne!(a, b); + } + + #[test] + fn sandbox_kills_infinite_loop() { + let lua = create_sandbox().unwrap(); + let result = lua.load("while true do end").exec(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("execution limit"), + "expected execution limit error, got: {err}" + ); + } + + #[test] + fn validate_script_accepts_valid() { + let result = validate_script("function handle() return {} end"); + assert!(result.is_ok()); + } + + #[test] + fn validate_script_rejects_missing_handle() { + let result = validate_script("function other() return {} end"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("handle")); + } + + #[test] + fn validate_script_rejects_syntax_error() { + let result = validate_script("function handle("); + assert!(result.is_err()); + } +} diff --git a/src/lua/tid.rs b/src/lua/tid.rs new file mode 100644 --- /dev/null +++ b/src/lua/tid.rs @@ -0,0 +1,76 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Base32-sortstring alphabet used by AT Protocol TIDs. +const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; + +/// Generate a TID (timestamp identifier) compatible with the AT Protocol spec. +/// +/// Layout: 64-bit value = `(microsecond_timestamp << 10) | random_10bit_clock_id` +/// Encoded as a 13-character base32-sortstring. +pub fn generate_tid() -> String { + let us = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before UNIX epoch") + .as_micros() as u64; + + // 10-bit random clock ID from UUID v4 bytes + let rand_bytes = uuid::Uuid::new_v4(); + let clock_id = u16::from_le_bytes([rand_bytes.as_bytes()[0], rand_bytes.as_bytes()[1]]) & 0x3FF; + + let val = (us << 10) | clock_id as u64; + encode_base32_sort(val) +} + +/// Encode a u64 into a 13-character base32-sortstring. +fn encode_base32_sort(mut val: u64) -> String { + let mut buf = [0u8; 13]; + for i in (0..13).rev() { + buf[i] = BASE32_SORT[(val & 0x1F) as usize]; + val >>= 5; + } + // SAFETY: all bytes come from BASE32_SORT which is ASCII + String::from_utf8(buf.to_vec()).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tid_is_13_chars() { + let tid = generate_tid(); + assert_eq!(tid.len(), 13, "TID should be 13 characters, got: {tid}"); + } + + #[test] + fn tid_uses_valid_charset() { + let tid = generate_tid(); + let valid = "234567abcdefghijklmnopqrstuvwxyz"; + for ch in tid.chars() { + assert!(valid.contains(ch), "invalid character '{ch}' in TID {tid}"); + } + } + + #[test] + fn tids_are_unique() { + let a = generate_tid(); + let b = generate_tid(); + assert_ne!(a, b, "two TIDs should differ"); + } + + #[test] + fn tids_are_sortable() { + // TIDs generated later should sort after earlier ones + let a = generate_tid(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let b = generate_tid(); + assert!(b > a, "later TID '{b}' should sort after earlier TID '{a}'"); + } + + #[test] + fn encode_base32_sort_known_value() { + // Zero should encode to all '2's (the first character in the alphabet) + let result = encode_base32_sort(0); + assert_eq!(result, "2222222222222"); + } +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,7 @@ lexicon_json.clone(), 1, target_collection.clone(), ProcedureAction::Upsert, + None, ) { Ok(parsed) => { if let Err(e) = sqlx::query( diff --git a/src/repo/at_uri.rs b/src/repo/at_uri.rs deleted file mode 100644 --- a/src/repo/at_uri.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::error::AppError; - -/// Extract the DID from an AT URI (at://did/collection/rkey). -#[allow(dead_code)] -pub(crate) fn parse_did_from_at_uri(uri: &str) -> Result { - let stripped = uri - .strip_prefix("at://") - .ok_or_else(|| AppError::Internal("AT URI must start with at://".into()))?; - - stripped - .split('/') - .next() - .map(|s| s.to_string()) - .ok_or_else(|| AppError::Internal("invalid AT URI".into())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_did_from_valid_at_uri() { - let did = parse_did_from_at_uri("at://did:plc:abc123/app.bsky.feed.post/3k2bqxyz").unwrap(); - assert_eq!(did, "did:plc:abc123"); - } - - #[test] - fn parse_did_from_uri_with_no_rkey() { - let did = parse_did_from_at_uri("at://did:plc:abc123/collection").unwrap(); - assert_eq!(did, "did:plc:abc123"); - } - - #[test] - fn parse_did_from_did_web_uri() { - let did = parse_did_from_at_uri("at://did:web:example.com/collection/rkey").unwrap(); - assert_eq!(did, "did:web:example.com"); - } - - #[test] - fn parse_did_from_uri_missing_prefix() { - let result = parse_did_from_at_uri("did:plc:abc123/collection/rkey"); - assert!(result.is_err()); - } -} diff --git a/src/repo/media.rs b/src/repo/media.rs deleted file mode 100644 --- a/src/repo/media.rs +++ /dev/null @@ -1,111 +0,0 @@ -use serde_json::{Value, json}; - -/// Walk `media[]` and add a `url` field to each blob so the frontend can -/// display images directly. -#[allow(dead_code)] -pub(crate) fn enrich_media_blobs(record: &mut Value, pds: &str, did: &str) { - let media = match record.get_mut("media").and_then(|m| m.as_array_mut()) { - Some(arr) => arr, - None => return, - }; - - let pds_base = pds.trim_end_matches('/'); - - for item in media.iter_mut() { - let cid = item - .get("blob") - .and_then(|b| b.get("ref")) - .and_then(|r| r.get("$link")) - .and_then(|l| l.as_str()) - .map(|s| s.to_string()); - - if let Some(cid) = cid - && let Some(blob) = item.get_mut("blob") - && let Some(obj) = blob.as_object_mut() - { - obj.insert( - "url".to_string(), - json!(format!( - "{pds_base}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" - )), - ); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enrich_media_adds_url() { - let mut record = json!({ - "media": [{ - "blob": { - "ref": { "$link": "bafyreiabc" }, - "mimeType": "image/jpeg", - "size": 1024 - } - }] - }); - - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); - - let url = record["media"][0]["blob"]["url"].as_str().unwrap(); - assert_eq!( - url, - "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreiabc" - ); - } - - #[test] - fn enrich_media_noop_without_media() { - let mut record = json!({"title": "test"}); - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); - assert!(record.get("media").is_none()); - } - - #[test] - fn enrich_media_skips_items_without_ref() { - let mut record = json!({ - "media": [{ - "blob": { "mimeType": "image/png" } - }] - }); - - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); - assert!(record["media"][0]["blob"].get("url").is_none()); - } - - #[test] - fn enrich_media_handles_multiple_items() { - let mut record = json!({ - "media": [ - { "blob": { "ref": { "$link": "cid1" } } }, - { "blob": { "ref": { "$link": "cid2" } } } - ] - }); - - enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:x"); - - let url1 = record["media"][0]["blob"]["url"].as_str().unwrap(); - let url2 = record["media"][1]["blob"]["url"].as_str().unwrap(); - assert!(url1.contains("cid1")); - assert!(url2.contains("cid2")); - } - - #[test] - fn enrich_media_trims_trailing_slash() { - let mut record = json!({ - "media": [{ - "blob": { "ref": { "$link": "bafytest" } } - }] - }); - - enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:test"); - - let url = record["media"][0]["blob"]["url"].as_str().unwrap(); - assert!(url.starts_with("https://pds.example.com/xrpc/")); - assert!(!url.contains("//xrpc")); - } -} diff --git a/src/repo/mod.rs b/src/repo/mod.rs --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,12 +1,8 @@ -mod at_uri; mod dpop; -mod media; mod pds; pub(crate) mod session; mod upload_blob; -pub(crate) use at_uri::parse_did_from_at_uri; -pub(crate) use media::enrich_media_blobs; pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; pub(crate) use session::{AtpSession, get_atp_session}; pub use upload_blob::upload_blob; diff --git a/src/tap.rs b/src/tap.rs --- a/src/tap.rs +++ b/src/tap.rs @@ -409,6 +409,7 @@ rec.clone(), 1, target_collection.clone(), ProcedureAction::Upsert, + None, ) { Ok(p) => p, Err(e) => { diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -15,6 +15,11 @@ claims: &Claims, input: &Value, lexicon: &crate::lexicon::ParsedLexicon, ) -> Result { + if let Some(ref script) = lexicon.script { + return crate::lua::execute_procedure_script(state, method, claims, input, lexicon, script) + .await; + } + let collection = lexicon.target_collection.as_deref().ok_or_else(|| { AppError::BadRequest(format!("{method} has no target_collection configured")) })?; diff --git a/src/xrpc/query.rs b/src/xrpc/query.rs --- a/src/xrpc/query.rs +++ b/src/xrpc/query.rs @@ -1,12 +1,10 @@ use axum::Json; use axum::response::{IntoResponse, Response}; use serde_json::{Value, json}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use crate::AppState; use crate::error::AppError; -use crate::profile; -use crate::repo; pub(super) async fn handle_query( state: &AppState, @@ -14,6 +12,10 @@ method: &str, params: &HashMap, lexicon: &crate::lexicon::ParsedLexicon, ) -> Result { + if let Some(ref script) = lexicon.script { + return crate::lua::execute_query_script(state, method, params, lexicon, script).await; + } + // Single-record query: has a `uri` parameter if let Some(uri) = params.get("uri") { return handle_get_record(state, uri).await; @@ -64,23 +66,9 @@ }; let has_next_page = rows.len() as i64 == limit; - // Resolve PDS endpoints for blob URL enrichment. - let unique_dids: HashSet<&str> = rows.iter().map(|(_, did, _)| did.as_str()).collect(); - let mut pds_map: HashMap = HashMap::new(); - for did in unique_dids { - if let Ok(pds) = - profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, did).await - { - pds_map.insert(did.to_string(), pds); - } - } - let records: Vec = rows .into_iter() - .map(|(uri, did, mut record)| { - if let Some(pds) = pds_map.get(&did) { - repo::enrich_media_blobs(&mut record, pds, &did); - } + .map(|(uri, _did, mut record)| { record .as_object_mut() .map(|obj| obj.insert("uri".to_string(), json!(uri))); @@ -101,8 +89,6 @@ Ok(Json(result).into_response()) } pub(super) async fn handle_get_record(state: &AppState, uri: &str) -> Result { - let did = repo::parse_did_from_at_uri(uri)?; - let row: Option<(Value,)> = sqlx::query_as("SELECT record FROM records WHERE uri = $1") .bind(uri) .fetch_optional(&state.db) @@ -110,9 +96,6 @@ .await .map_err(|e| AppError::Internal(format!("DB query failed: {e}")))?; let (mut record,) = row.ok_or_else(|| AppError::NotFound("record not found".into()))?; - - let pds = profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &did).await?; - repo::enrich_media_blobs(&mut record, &pds, &did); record .as_object_mut() diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", @@ -1769,6 +1770,29 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -4253,6 +4277,14 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6272,6 +6304,16 @@ "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -9335,6 +9377,19 @@ "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10065,6 +10120,17 @@ "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" } }, "node_modules/ms": { @@ -12129,6 +12195,12 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "dev": true, + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, "node_modules/statuses": { diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", diff --git a/web/src/app/(dashboard)/lexicons/[id]/page.tsx b/web/src/app/(dashboard)/lexicons/[id]/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/(dashboard)/lexicons/[id]/page.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; + +import { useAuth } from "@/lib/auth-context"; +import { CodePanels } from "@/components/code-panels"; +import { + deleteLexicon, + deleteNetworkLexicon, + getLexicon, + uploadLexicon, + type LexiconDetail, +} from "@/lib/api"; +import { procedureScript, queryScript } from "@/lib/lua-templates"; +import { useLuaCompletions } from "@/hooks/use-lua-completions"; +import { SiteHeader } from "@/components/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +export default function LexiconDetailPage() { + const { id } = useParams<{ id: string }>(); + const { getToken } = useAuth(); + const router = useRouter(); + const [lexicon, setLexicon] = useState(null); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + const [saving, setSaving] = useState(false); + + // Editable text state + const [jsonText, setJsonText] = useState(""); + const [luaText, setLuaText] = useState(""); + const [originalJson, setOriginalJson] = useState(""); + const [originalLua, setOriginalLua] = useState(""); + const { luaCompletions, collections } = useLuaCompletions(jsonText); + + const load = useCallback(() => { + getLexicon(getToken, id) + .then((lex) => { + setLexicon(lex); + const json = JSON.stringify(lex.lexicon_json, null, 2); + setJsonText(json); + setOriginalJson(json); + + // If lexicon has no script but is a query/procedure, auto-generate one + if ( + !lex.script && + (lex.lexicon_type === "query" || lex.lexicon_type === "procedure") + ) { + const generated = + lex.lexicon_type === "procedure" + ? procedureScript(lex.target_collection ?? "") + : queryScript(lex.target_collection ?? ""); + setLuaText(generated); + // Set originalLua to "" so isDirty becomes true, prompting user to save + setOriginalLua(""); + } else { + setLuaText(lex.script ?? ""); + setOriginalLua(lex.script ?? ""); + } + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); + }, [getToken, id]); + + useEffect(() => { + load(); + }, [load]); + + const isDirty = jsonText !== originalJson || luaText !== originalLua; + + async function handleSave() { + if (!lexicon) return; + setSaving(true); + setError(null); + try { + const lexiconJson = JSON.parse(jsonText); + await uploadLexicon(getToken, { + lexicon_json: lexiconJson, + backfill: lexicon.backfill, + script: luaText || undefined, + }); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!lexicon) return; + setDeleting(true); + try { + if (lexicon.source === "network") { + await deleteNetworkLexicon(getToken, lexicon.id); + } else { + await deleteLexicon(getToken, lexicon.id); + } + router.push("/lexicons"); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setDeleting(false); + } + } + + if (error && !lexicon) { + return ( + <> + +
+

{error}

+
+ + ); + } + + if (!lexicon) { + return ( + <> + +
+

Loading...

+
+ + ); + } + + const isNetwork = lexicon.source === "network"; + const showLua = + lexicon.has_script || + lexicon.lexicon_type === "query" || + lexicon.lexicon_type === "procedure"; + + return ( +
+ +
+
+ {error &&

{error}

} + + {/* Metadata */} +
+
+ +
+ {lexicon.lexicon_type} +
+
+
+ +
+ + {lexicon.source} + +
+
+
+ +

{lexicon.revision}

+
+
+ +

{lexicon.backfill ? "Yes" : "No"}

+
+ {lexicon.authority_did && ( +
+ +

+ {lexicon.authority_did} +

+
+ )} +
+ +

+ {new Date(lexicon.created_at).toLocaleString()} +

+
+
+ +

+ {new Date(lexicon.updated_at).toLocaleString()} +

+
+ {lexicon.last_fetched_at && ( +
+ +

+ {new Date(lexicon.last_fetched_at).toLocaleString()} +

+
+ )} +
+
+ + {/* Code Panels */} + + + {/* Actions */} +
+ + + +
+
+
+ ); +} diff --git a/web/src/app/(dashboard)/lexicons/new/page.tsx b/web/src/app/(dashboard)/lexicons/new/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/(dashboard)/lexicons/new/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty"; +import { useAuth } from "@/lib/auth-context"; +import { + addNetworkLexicon, + uploadLexicon, +} from "@/lib/api"; +import { resolveNsid } from "@/lib/nsid"; +import { LEXICON_TEMPLATE, procedureScript, queryScript } from "@/lib/lua-templates"; +import { useLuaCompletions } from "@/hooks/use-lua-completions"; +import { CodePanels } from "@/components/code-panels"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export default function AddLexiconPage() { + const { getToken } = useAuth(); + const router = useRouter(); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Local state + const [json, setJson] = useState(LEXICON_TEMPLATE); + const [localTargetCollection, setLocalTargetCollection] = useState(""); + const [script, setScript] = useState(""); + const [backfill, setBackfill] = useState(true); + const scriptManuallyEdited = useRef(false); + + // Collections for Record() completions and record schemas + const { luaCompletions, collections } = useLuaCompletions(json); + + // Network state + const [nsid, setNsid] = useState(""); + const [networkTargetCollection, setNetworkTargetCollection] = useState(""); + const [networkJson, setNetworkJson] = useState(""); + const [mainType, setMainType] = useState(); + const [resolving, setResolving] = useState(false); + const abortRef = useRef(null); + + const lastValidType = useRef(undefined); + const localMainType = useMemo(() => { + try { + const parsed = JSON.parse(json); + const type = parsed?.defs?.main?.type as string | undefined; + lastValidType.current = type; + return type; + } catch { + return lastValidType.current; + } + }, [json]); + + const showLocalTargetCollection = + localMainType === "query" || localMainType === "procedure"; + const showScript = localMainType === "query" || localMainType === "procedure"; + + // Auto-generate script when type or target collection changes + useEffect(() => { + if (scriptManuallyEdited.current) return; + if (localMainType === "procedure") { + setScript(procedureScript(localTargetCollection)); + } else if (localMainType === "query") { + setScript(queryScript(localTargetCollection)); + } + }, [localMainType, localTargetCollection]); + + function handleScriptChange(value: string) { + scriptManuallyEdited.current = true; + setScript(value); + } + + // Reset manual-edit flag when type changes + const prevType = useRef(localMainType); + useEffect(() => { + if (prevType.current !== localMainType) { + scriptManuallyEdited.current = false; + prevType.current = localMainType; + } + }, [localMainType]); + + // Debounced NSID resolution + useEffect(() => { + abortRef.current?.abort(); + setMainType(undefined); + setNetworkJson(""); + + if (nsid.split(".").length < 3) return; + + const debounce = setTimeout(() => { + const controller = new AbortController(); + abortRef.current = controller; + setResolving(true); + + resolveNsid(nsid, controller.signal) + .then((result) => { + if (!controller.signal.aborted) { + setMainType(result.type); + setNetworkJson( + result.lexiconJson + ? JSON.stringify(result.lexiconJson, null, 2) + : "", + ); + } + }) + .finally(() => { + if (!controller.signal.aborted) setResolving(false); + }); + }, 500); + + return () => clearTimeout(debounce); + }, [nsid]); + + const showNetworkTargetCollection = + mainType === "query" || mainType === "procedure"; + + async function handleUploadLocal() { + setError(null); + setSubmitting(true); + try { + const lexiconJson = JSON.parse(json); + await uploadLexicon(getToken, { + lexicon_json: lexiconJson, + backfill, + script: showScript && script ? script : undefined, + }); + router.push("/lexicons"); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + } + + async function handleAddNetwork() { + setError(null); + setSubmitting(true); + try { + await addNetworkLexicon(getToken, { + nsid, + target_collection: showNetworkTargetCollection + ? networkTargetCollection || undefined + : undefined, + }); + router.push("/lexicons"); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + } + + return ( + <> + + +
+ +
+ + + Local + + + Network + + +
+ + +
+ {error &&

{error}

} + + {/* Metadata fields */} + {/*
+ {showLocalTargetCollection && ( +
+ + setLocalTargetCollection(e.target.value)} + placeholder="com.example.record" + /> +
+ )} +
*/} + + {/* Code panels */} + +
+ +
+
+ + +
+ + +
+
+ + +
+ {error &&

{error}

} + +
+
+ + setNsid(e.target.value)} + placeholder="com.example.record" + /> +
+ + {showNetworkTargetCollection && ( +
+ + + setNetworkTargetCollection(e.target.value) + } + placeholder="com.example.record" + /> +
+ )} +
+ + {resolving && ( + + {"Resolving lexicon..."} + + )} + + {Boolean(nsid) && !resolving && !networkJson && ( + + {"Not found"} + + + {"There are no lexicons on the network with NSID:"} +
+ {nsid} +
+
+ )} + + {networkJson && ( + + )} +
+ +
+ +
+
+
+
+ + ); +} diff --git a/web/src/app/(dashboard)/lexicons/page.tsx b/web/src/app/(dashboard)/lexicons/page.tsx --- a/web/src/app/(dashboard)/lexicons/page.tsx +++ b/web/src/app/(dashboard)/lexicons/page.tsx @@ -14,18 +14,14 @@ getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useAuth } from "@/lib/auth-context"; -import { CodeBlock } from "@/components/code-block"; import { - addNetworkLexicon, deleteLexicon, deleteNetworkLexicon, - getLexicon, getLexicons, - uploadLexicon, - type LexiconDetail, type LexiconSummary, } from "@/lib/api"; import { DataTable } from "@/components/data-table/data-table"; @@ -34,34 +30,11 @@ import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; import { SiteHeader } from "@/components/site-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; export default function LexiconsPage() { const { getToken } = useAuth(); const [lexicons, setLexicons] = useState([]); const [error, setError] = useState(null); - const [viewLexicon, setViewLexicon] = useState(null); const load = useCallback(() => { getLexicons(getToken) @@ -72,15 +45,6 @@ useEffect(() => { load(); }, [load]); - - async function handleView(id: string) { - try { - const detail = await getLexicon(getToken, id); - setViewLexicon(detail); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } async function handleDelete(lex: LexiconSummary) { try { @@ -183,6 +147,20 @@ cell: ({ row }) => row.original.action ?? "--", enableSorting: true, }, { + id: "has_script", + accessorKey: "has_script", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.has_script ? ( + Lua + ) : ( + "--" + ), + enableSorting: true, + }, + { id: "backfill", accessorKey: "backfill", header: ({ column }) => ( @@ -207,12 +185,10 @@ id: "actions", header: () => Actions, cell: ({ row }) => (
- - - {viewLexicon && ( - setViewLexicon(null)}> - - - {viewLexicon.id} - - Revision {viewLexicon.revision} ·{" "} - {viewLexicon.lexicon_type} - - - - - - )}
); } - -// --------------------------------------------------------------------------- -// Unified Add Lexicon dialog -// --------------------------------------------------------------------------- - -function AddLexiconDialog({ - getToken, - onSuccess, -}: { - getToken: () => Promise; - onSuccess: () => void; -}) { - const [open, setOpen] = useState(false); - const [error, setError] = useState(null); - - // Local state - const [json, setJson] = useState(""); - const [localTargetCollection, setLocalTargetCollection] = useState(""); - const [action, setAction] = useState(""); - const [backfill, setBackfill] = useState(true); - - // Network state - const [nsid, setNsid] = useState(""); - const [networkTargetCollection, setNetworkTargetCollection] = useState(""); - const [mainType, setMainType] = useState(); - const [resolving, setResolving] = useState(false); - const abortRef = useRef(null); - - const localMainType = useMemo(() => { - try { - const parsed = JSON.parse(json); - return parsed?.defs?.main?.type as string | undefined; - } catch { - return undefined; - } - }, [json]); - - const showLocalTargetCollection = - localMainType === "query" || localMainType === "procedure"; - const showAction = localMainType === "procedure"; - - // Debounced NSID resolution - useEffect(() => { - abortRef.current?.abort(); - setMainType(undefined); - - if (nsid.split(".").length < 3) return; - - const debounce = setTimeout(() => { - const controller = new AbortController(); - abortRef.current = controller; - setResolving(true); - - resolveNsidType(nsid, controller.signal) - .then((type) => { - if (!controller.signal.aborted) setMainType(type); - }) - .finally(() => { - if (!controller.signal.aborted) setResolving(false); - }); - }, 500); - - return () => clearTimeout(debounce); - }, [nsid]); - - const showNetworkTargetCollection = - mainType === "query" || mainType === "procedure"; - - function reset() { - setError(null); - setJson(""); - setLocalTargetCollection(""); - setAction(""); - setBackfill(true); - setNsid(""); - setNetworkTargetCollection(""); - setMainType(undefined); - } - - async function handleUploadLocal() { - setError(null); - try { - const lexiconJson = JSON.parse(json); - await uploadLexicon(getToken, { - lexicon_json: lexiconJson, - backfill, - target_collection: showLocalTargetCollection - ? localTargetCollection || undefined - : undefined, - action: showAction ? action || undefined : undefined, - }); - reset(); - setOpen(false); - onSuccess(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - async function handleAddNetwork() { - setError(null); - try { - await addNetworkLexicon(getToken, { - nsid, - target_collection: showNetworkTargetCollection - ? networkTargetCollection || undefined - : undefined, - }); - reset(); - setOpen(false); - onSuccess(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - return ( - { - setOpen(v); - if (!v) reset(); - }} - > - - - - - - Add Lexicon - - Upload a local lexicon JSON document or track one from the network. - - - - - - Local - - - Network - - - - -
- {error &&

{error}

} -
- -