···44pub mod config;
55pub mod error;
66pub mod lexicon;
77+pub mod lua;
78pub mod profile;
89pub mod repo;
910pub mod resolve;
+33
src/lua/context.rs
···11+use mlua::{Lua, LuaSerdeExt, Result as LuaResult};
22+use serde_json::Value;
33+use std::collections::HashMap;
44+55+/// Set global context variables for a procedure script.
66+pub fn set_procedure_context(
77+ lua: &Lua,
88+ method: &str,
99+ input: &Value,
1010+ caller_did: &str,
1111+ collection: &str,
1212+) -> LuaResult<()> {
1313+ let globals = lua.globals();
1414+ globals.set("method", method.to_string())?;
1515+ globals.set("input", lua.to_value(input)?)?;
1616+ globals.set("caller_did", caller_did.to_string())?;
1717+ globals.set("collection", collection.to_string())?;
1818+ Ok(())
1919+}
2020+2121+/// Set global context variables for a query script.
2222+pub fn set_query_context(
2323+ lua: &Lua,
2424+ method: &str,
2525+ params: &HashMap<String, String>,
2626+ collection: &str,
2727+) -> LuaResult<()> {
2828+ let globals = lua.globals();
2929+ globals.set("method", method.to_string())?;
3030+ globals.set("params", lua.to_value(params)?)?;
3131+ globals.set("collection", collection.to_string())?;
3232+ Ok(())
3333+}
+119
src/lua/db_api.rs
···11+use mlua::{Lua, LuaSerdeExt, Result as LuaResult};
22+use serde_json::{Value, json};
33+use std::sync::Arc;
44+55+use crate::AppState;
66+77+/// Register the `db` table with read-only database query functions.
88+pub fn register_db_api(lua: &Lua, state: Arc<AppState>) -> LuaResult<()> {
99+ let db_table = lua.create_table()?;
1010+1111+ // db.query({ collection, did?, limit?, offset? }) -> { records, cursor? }
1212+ let state_query = state.clone();
1313+ let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| {
1414+ let state = state_query.clone();
1515+ async move {
1616+ let collection: String = opts.get("collection")?;
1717+ let did: Option<String> = opts.get("did").ok();
1818+ let limit: i64 = opts.get::<i64>("limit").unwrap_or(20).min(100);
1919+ let offset: i64 = opts.get::<i64>("offset").unwrap_or(0);
2020+2121+ let rows: Vec<(String, String, Value)> = if let Some(ref did) = did {
2222+ sqlx::query_as(
2323+ "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 ORDER BY indexed_at DESC LIMIT $3 OFFSET $4",
2424+ )
2525+ .bind(&collection)
2626+ .bind(did)
2727+ .bind(limit)
2828+ .bind(offset)
2929+ .fetch_all(&state.db)
3030+ .await
3131+ .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?
3232+ } else {
3333+ sqlx::query_as(
3434+ "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3",
3535+ )
3636+ .bind(&collection)
3737+ .bind(limit)
3838+ .bind(offset)
3939+ .fetch_all(&state.db)
4040+ .await
4141+ .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?
4242+ };
4343+4444+ let has_next = rows.len() as i64 == limit;
4545+ let records: Vec<Value> = rows
4646+ .into_iter()
4747+ .map(|(uri, _did, mut record)| {
4848+ if let Some(obj) = record.as_object_mut() {
4949+ obj.insert("uri".to_string(), json!(uri));
5050+ }
5151+ record
5252+ })
5353+ .collect();
5454+5555+ let mut result = json!({ "records": records });
5656+ if has_next {
5757+ let next_cursor = (offset + limit).to_string();
5858+ result.as_object_mut().unwrap().insert("cursor".to_string(), json!(next_cursor));
5959+ }
6060+6161+ lua.to_value(&result)
6262+ }
6363+ })?;
6464+ db_table.set("query", query_fn)?;
6565+6666+ // db.get(uri) -> record table or nil
6767+ let state_get = state.clone();
6868+ let get_fn = lua.create_async_function(move |lua, uri: String| {
6969+ let state = state_get.clone();
7070+ async move {
7171+ let row: Option<(Value,)> = sqlx::query_as("SELECT record FROM records WHERE uri = $1")
7272+ .bind(&uri)
7373+ .fetch_optional(&state.db)
7474+ .await
7575+ .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?;
7676+7777+ match row {
7878+ Some((mut record,)) => {
7979+ if let Some(obj) = record.as_object_mut() {
8080+ obj.insert("uri".to_string(), json!(uri));
8181+ }
8282+ lua.to_value(&record)
8383+ }
8484+ None => Ok(mlua::Value::Nil),
8585+ }
8686+ }
8787+ })?;
8888+ db_table.set("get", get_fn)?;
8989+9090+ // db.count(collection, did?) -> integer
9191+ let state_count = state;
9292+ let count_fn =
9393+ lua.create_async_function(move |_, (collection, did): (String, Option<String>)| {
9494+ let state = state_count.clone();
9595+ async move {
9696+ let count: (i64,) = if let Some(ref did) = did {
9797+ sqlx::query_as(
9898+ "SELECT COUNT(*) FROM records WHERE collection = $1 AND did = $2",
9999+ )
100100+ .bind(&collection)
101101+ .bind(did)
102102+ .fetch_one(&state.db)
103103+ .await
104104+ .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))?
105105+ } else {
106106+ sqlx::query_as("SELECT COUNT(*) FROM records WHERE collection = $1")
107107+ .bind(&collection)
108108+ .fetch_one(&state.db)
109109+ .await
110110+ .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))?
111111+ };
112112+ Ok(count.0)
113113+ }
114114+ })?;
115115+ db_table.set("count", count_fn)?;
116116+117117+ lua.globals().set("db", db_table)?;
118118+ Ok(())
119119+}
+123
src/lua/execute.rs
···11+use axum::Json;
22+use axum::response::{IntoResponse, Response};
33+use mlua::LuaSerdeExt;
44+use serde_json::Value;
55+use std::collections::HashMap;
66+use std::sync::Arc;
77+88+use crate::AppState;
99+use crate::auth::Claims;
1010+use crate::error::AppError;
1111+use crate::lexicon::ParsedLexicon;
1212+use crate::repo;
1313+1414+use super::context;
1515+use super::db_api;
1616+use super::record;
1717+use super::sandbox;
1818+1919+/// Execute a Lua script for a procedure endpoint.
2020+pub async fn execute_procedure_script(
2121+ state: &AppState,
2222+ method: &str,
2323+ claims: &Claims,
2424+ input: &Value,
2525+ lexicon: &ParsedLexicon,
2626+ script: &str,
2727+) -> Result<Response, AppError> {
2828+ let collection = lexicon.target_collection.as_deref().unwrap_or_default();
2929+3030+ let session = repo::get_atp_session(state, claims.token()).await?;
3131+3232+ let lua = sandbox::create_sandbox()
3333+ .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?;
3434+3535+ let state_arc = Arc::new(state.clone());
3636+ let claims_arc = Arc::new(claims.clone());
3737+ let session_arc = Arc::new(session);
3838+3939+ db_api::register_db_api(&lua, state_arc.clone())
4040+ .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?;
4141+4242+ record::register_record_api(&lua, state_arc, claims_arc, session_arc)
4343+ .map_err(|e| AppError::Internal(format!("failed to register Record API: {e}")))?;
4444+4545+ context::set_procedure_context(&lua, method, input, claims.did(), collection)
4646+ .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?;
4747+4848+ lua.load(script).exec().map_err(|e| {
4949+ tracing::error!(method, error = %e, "lua script load failed");
5050+ AppError::Internal("script execution failed".into())
5151+ })?;
5252+5353+ let handle: mlua::Function = lua.globals().get("handle").map_err(|e| {
5454+ tracing::error!(method, error = %e, "lua script missing handle function");
5555+ AppError::Internal("script execution failed".into())
5656+ })?;
5757+5858+ let result: mlua::Value = handle.call_async(()).await.map_err(|e| {
5959+ let msg = e.to_string();
6060+ tracing::error!(method, error = %msg, "lua script execution failed");
6161+ if msg.contains("execution limit") {
6262+ AppError::Internal("script exceeded execution time limit".into())
6363+ } else {
6464+ AppError::Internal("script execution failed".into())
6565+ }
6666+ })?;
6767+6868+ let json_value: Value = lua.from_value(result).map_err(|e| {
6969+ tracing::error!(method, error = %e, "failed to convert lua result to JSON");
7070+ AppError::Internal("script execution failed".into())
7171+ })?;
7272+7373+ Ok(Json(json_value).into_response())
7474+}
7575+7676+/// Execute a Lua script for a query endpoint.
7777+pub async fn execute_query_script(
7878+ state: &AppState,
7979+ method: &str,
8080+ params: &HashMap<String, String>,
8181+ lexicon: &ParsedLexicon,
8282+ script: &str,
8383+) -> Result<Response, AppError> {
8484+ let collection = lexicon.target_collection.as_deref().unwrap_or_default();
8585+8686+ let lua = sandbox::create_sandbox()
8787+ .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?;
8888+8989+ let state_arc = Arc::new(state.clone());
9090+9191+ db_api::register_db_api(&lua, state_arc)
9292+ .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?;
9393+9494+ context::set_query_context(&lua, method, params, collection)
9595+ .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?;
9696+9797+ lua.load(script).exec().map_err(|e| {
9898+ tracing::error!(method, error = %e, "lua script load failed");
9999+ AppError::Internal("script execution failed".into())
100100+ })?;
101101+102102+ let handle: mlua::Function = lua.globals().get("handle").map_err(|e| {
103103+ tracing::error!(method, error = %e, "lua script missing handle function");
104104+ AppError::Internal("script execution failed".into())
105105+ })?;
106106+107107+ let result: mlua::Value = handle.call_async(()).await.map_err(|e| {
108108+ let msg = e.to_string();
109109+ tracing::error!(method, error = %msg, "lua script execution failed");
110110+ if msg.contains("execution limit") {
111111+ AppError::Internal("script exceeded execution time limit".into())
112112+ } else {
113113+ AppError::Internal("script execution failed".into())
114114+ }
115115+ })?;
116116+117117+ let json_value: Value = lua.from_value(result).map_err(|e| {
118118+ tracing::error!(method, error = %e, "failed to convert lua result to JSON");
119119+ AppError::Internal("script execution failed".into())
120120+ })?;
121121+122122+ Ok(Json(json_value).into_response())
123123+}
+9
src/lua/mod.rs
···11+mod context;
22+mod db_api;
33+mod execute;
44+mod record;
55+pub(crate) mod sandbox;
66+mod tid;
77+88+pub(crate) use execute::{execute_procedure_script, execute_query_script};
99+pub(crate) use sandbox::validate_script;
+840
src/lua/record.rs
···11+use futures_util::future::try_join_all;
22+use mlua::{Lua, LuaSerdeExt, Result as LuaResult};
33+use serde_json::{Value, json};
44+use std::sync::Arc;
55+66+use crate::AppState;
77+use crate::auth::Claims;
88+use crate::repo::{self, AtpSession};
99+1010+use super::tid::generate_tid;
1111+1212+const INTERNAL_FIELDS: &[&str] = &[
1313+ "_collection",
1414+ "_uri",
1515+ "_cid",
1616+ "_schema",
1717+ "_key_type",
1818+ "_rkey",
1919+];
2020+2121+/// Register the `Record` global constructor and static methods.
2222+/// Only registered for procedure scripts (not queries).
2323+pub fn register_record_api(
2424+ lua: &Lua,
2525+ state: Arc<AppState>,
2626+ claims: Arc<Claims>,
2727+ session: Arc<AtpSession>,
2828+) -> LuaResult<()> {
2929+ // -- methods table (shared by all Record instances) --
3030+ let methods = lua.create_table()?;
3131+3232+ // :save()
3333+ {
3434+ let state = state.clone();
3535+ let claims = claims.clone();
3636+ let session = session.clone();
3737+ let save_fn = lua.create_async_function(move |lua, this: mlua::Table| {
3838+ let state = state.clone();
3939+ let claims = claims.clone();
4040+ let session = session.clone();
4141+ async move {
4242+ let collection: String = this.raw_get("_collection")?;
4343+ let schema: mlua::Value = this.raw_get("_schema")?;
4444+4545+ // Validate required fields against schema
4646+ if let mlua::Value::Table(ref schema_table) = schema {
4747+ validate_required_fields(&this, schema_table)?;
4848+ }
4949+5050+ // Serialize record data (skip _ keys, inject $type)
5151+ let data = extract_record_data(&lua, &this, &collection)?;
5252+5353+ let existing_uri: Option<String> = this.raw_get("_uri")?;
5454+5555+ let pds_result = if let Some(ref uri) = existing_uri {
5656+ // PUT
5757+ let rkey = uri
5858+ .split('/')
5959+ .next_back()
6060+ .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))?
6161+ .to_string();
6262+6363+ let pds_body = json!({
6464+ "repo": claims.did(),
6565+ "collection": collection,
6666+ "rkey": rkey,
6767+ "record": data,
6868+ });
6969+7070+ let resp = repo::pds_post_json_raw(
7171+ &state,
7272+ &session,
7373+ "com.atproto.repo.putRecord",
7474+ &pds_body,
7575+ )
7676+ .await
7777+ .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?;
7878+7979+ if !resp.status().is_success() {
8080+ let status = resp.status();
8181+ let body = resp.text().await.unwrap_or_default();
8282+ return Err(mlua::Error::runtime(format!(
8383+ "PDS putRecord returned {status}: {body}"
8484+ )));
8585+ }
8686+8787+ let bytes = resp.bytes().await.map_err(|e| {
8888+ mlua::Error::runtime(format!("failed to read PDS response: {e}"))
8989+ })?;
9090+ let result: Value = serde_json::from_slice(&bytes)
9191+ .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?;
9292+9393+ // Upsert local DB
9494+ let cid = result
9595+ .get("cid")
9696+ .and_then(|v| v.as_str())
9797+ .unwrap_or_default();
9898+ let _ = sqlx::query(
9999+ r#"INSERT INTO records (uri, did, collection, rkey, record, cid)
100100+ VALUES ($1, $2, $3, $4, $5, $6)
101101+ ON CONFLICT (uri) DO UPDATE
102102+ SET record = EXCLUDED.record,
103103+ cid = EXCLUDED.cid,
104104+ indexed_at = NOW()"#,
105105+ )
106106+ .bind(uri)
107107+ .bind(claims.did())
108108+ .bind(&collection)
109109+ .bind(&rkey)
110110+ .bind(&data)
111111+ .bind(cid)
112112+ .execute(&state.db)
113113+ .await;
114114+115115+ result
116116+ } else {
117117+ // CREATE
118118+ let rkey: Option<String> = this.raw_get("_rkey")?;
119119+ let mut pds_body = json!({
120120+ "repo": claims.did(),
121121+ "collection": collection,
122122+ "record": data,
123123+ });
124124+ if let Some(ref rkey) = rkey {
125125+ pds_body["rkey"] = json!(rkey);
126126+ }
127127+128128+ let resp = repo::pds_post_json_raw(
129129+ &state,
130130+ &session,
131131+ "com.atproto.repo.createRecord",
132132+ &pds_body,
133133+ )
134134+ .await
135135+ .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?;
136136+137137+ if !resp.status().is_success() {
138138+ let status = resp.status();
139139+ let body = resp.text().await.unwrap_or_default();
140140+ return Err(mlua::Error::runtime(format!(
141141+ "PDS createRecord returned {status}: {body}"
142142+ )));
143143+ }
144144+145145+ let bytes = resp.bytes().await.map_err(|e| {
146146+ mlua::Error::runtime(format!("failed to read PDS response: {e}"))
147147+ })?;
148148+ let result: Value = serde_json::from_slice(&bytes)
149149+ .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?;
150150+151151+ // Upsert local DB
152152+ if let (Some(uri), Some(cid)) = (
153153+ result.get("uri").and_then(|v| v.as_str()),
154154+ result.get("cid").and_then(|v| v.as_str()),
155155+ ) {
156156+ let rkey = uri.split('/').next_back().unwrap_or_default();
157157+ let _ = sqlx::query(
158158+ r#"INSERT INTO records (uri, did, collection, rkey, record, cid)
159159+ VALUES ($1, $2, $3, $4, $5, $6)
160160+ ON CONFLICT (uri) DO UPDATE
161161+ SET record = EXCLUDED.record,
162162+ cid = EXCLUDED.cid"#,
163163+ )
164164+ .bind(uri)
165165+ .bind(claims.did())
166166+ .bind(&collection)
167167+ .bind(rkey)
168168+ .bind(&data)
169169+ .bind(cid)
170170+ .execute(&state.db)
171171+ .await;
172172+ }
173173+174174+ result
175175+ };
176176+177177+ // Write back _uri and _cid
178178+ if let Some(uri) = pds_result.get("uri").and_then(|v| v.as_str()) {
179179+ this.raw_set("_uri", uri.to_string())?;
180180+ }
181181+ if let Some(cid) = pds_result.get("cid").and_then(|v| v.as_str()) {
182182+ this.raw_set("_cid", cid.to_string())?;
183183+ }
184184+185185+ Ok(this)
186186+ }
187187+ })?;
188188+ methods.set("save", save_fn)?;
189189+ }
190190+191191+ // :delete()
192192+ {
193193+ let state = state.clone();
194194+ let claims = claims.clone();
195195+ let session = session.clone();
196196+ let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| {
197197+ let state = state.clone();
198198+ let claims = claims.clone();
199199+ let session = session.clone();
200200+ async move {
201201+ let uri: String = this.raw_get::<Option<String>>("_uri")?.ok_or_else(|| {
202202+ mlua::Error::runtime("cannot delete a Record that has no _uri")
203203+ })?;
204204+ let collection: String = this.raw_get("_collection")?;
205205+206206+ let rkey = uri
207207+ .split('/')
208208+ .next_back()
209209+ .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))?
210210+ .to_string();
211211+212212+ let pds_body = json!({
213213+ "repo": claims.did(),
214214+ "collection": collection,
215215+ "rkey": rkey,
216216+ });
217217+218218+ let resp = repo::pds_post_json_raw(
219219+ &state,
220220+ &session,
221221+ "com.atproto.repo.deleteRecord",
222222+ &pds_body,
223223+ )
224224+ .await
225225+ .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?;
226226+227227+ if !resp.status().is_success() {
228228+ let status = resp.status();
229229+ let body = resp.text().await.unwrap_or_default();
230230+ return Err(mlua::Error::runtime(format!(
231231+ "PDS deleteRecord returned {status}: {body}"
232232+ )));
233233+ }
234234+235235+ // Delete from local DB
236236+ let _ = sqlx::query("DELETE FROM records WHERE uri = $1")
237237+ .bind(&uri)
238238+ .execute(&state.db)
239239+ .await;
240240+241241+ // Clear _uri and _cid
242242+ this.raw_set("_uri", mlua::Value::Nil)?;
243243+ this.raw_set("_cid", mlua::Value::Nil)?;
244244+245245+ Ok(this)
246246+ }
247247+ })?;
248248+ methods.set("delete", delete_fn)?;
249249+ }
250250+251251+ // :set_key_type(type)
252252+ {
253253+ let set_key_type_fn =
254254+ lua.create_function(|_lua, (this, key_type): (mlua::Table, String)| {
255255+ match key_type.as_str() {
256256+ "tid" | "any" | "nsid" => {}
257257+ s if s.starts_with("literal:") && s.len() > "literal:".len() => {}
258258+ _ => {
259259+ return Err(mlua::Error::runtime(format!(
260260+ "invalid key type '{key_type}': expected tid, any, nsid, or literal:*"
261261+ )));
262262+ }
263263+ }
264264+ this.raw_set("_key_type", key_type)?;
265265+ Ok(this)
266266+ })?;
267267+ methods.set("set_key_type", set_key_type_fn)?;
268268+ }
269269+270270+ // :set_rkey(key)
271271+ {
272272+ let set_rkey_fn = lua.create_function(|_lua, (this, key): (mlua::Table, String)| {
273273+ if key.is_empty() {
274274+ return Err(mlua::Error::runtime("rkey must be a non-empty string"));
275275+ }
276276+ this.raw_set("_rkey", key)?;
277277+ Ok(this)
278278+ })?;
279279+ methods.set("set_rkey", set_rkey_fn)?;
280280+ }
281281+282282+ // :generate_rkey()
283283+ {
284284+ let generate_rkey_fn = lua.create_function(|_lua, this: mlua::Table| {
285285+ let key_type: Option<String> = this.raw_get("_key_type")?;
286286+ let rkey = match key_type.as_deref() {
287287+ Some("tid") | Some("any") => generate_tid(),
288288+ Some(s) if s.starts_with("literal:") => s["literal:".len()..].to_string(),
289289+ Some("nsid") => {
290290+ return Err(mlua::Error::runtime(
291291+ "cannot auto-generate rkey for nsid key type — use set_rkey() instead",
292292+ ));
293293+ }
294294+ Some(other) => {
295295+ return Err(mlua::Error::runtime(format!("unknown key type '{other}'")));
296296+ }
297297+ None => {
298298+ return Err(mlua::Error::runtime(
299299+ "no _key_type set — call set_key_type() first or use a record-type lexicon",
300300+ ));
301301+ }
302302+ };
303303+ this.raw_set("_rkey", rkey.as_str())?;
304304+ Ok(rkey)
305305+ })?;
306306+ methods.set("generate_rkey", generate_rkey_fn)?;
307307+ }
308308+309309+ // -- metatable --
310310+ let metatable = lua.create_table()?;
311311+312312+ // __index: check methods first, then rawget
313313+ {
314314+ let methods_ref = methods.clone();
315315+ let index_fn = lua.create_function(move |_lua, (this, key): (mlua::Table, String)| {
316316+ // Check methods table first
317317+ let method: mlua::Value = methods_ref.raw_get(key.as_str())?;
318318+ if !method.is_nil() {
319319+ return Ok(method);
320320+ }
321321+ // Fall through to raw field access
322322+ this.raw_get::<mlua::Value>(key.as_str())
323323+ })?;
324324+ metatable.set("__index", index_fn)?;
325325+ }
326326+327327+ // __newindex: block writes to internal fields
328328+ {
329329+ let newindex_fn = lua.create_function(
330330+ move |_lua, (this, key, value): (mlua::Table, String, mlua::Value)| {
331331+ if INTERNAL_FIELDS.contains(&key.as_str()) {
332332+ return Err(mlua::Error::runtime(format!(
333333+ "cannot assign to internal field '{key}'"
334334+ )));
335335+ }
336336+ this.raw_set(key, value)?;
337337+ Ok(())
338338+ },
339339+ )?;
340340+ metatable.set("__newindex", newindex_fn)?;
341341+ }
342342+343343+ // __tostring
344344+ {
345345+ let tostring_fn = lua.create_function(|_lua, this: mlua::Table| {
346346+ let collection: String = this.raw_get("_collection")?;
347347+ let uri: Option<String> = this.raw_get("_uri")?;
348348+ match uri {
349349+ Some(u) => Ok(format!("Record({collection}) [uri={u}]")),
350350+ None => Ok(format!("Record({collection}) [unsaved]")),
351351+ }
352352+ })?;
353353+ metatable.set("__tostring", tostring_fn)?;
354354+ }
355355+356356+ // -- Record constructor function --
357357+ let record_table = lua.create_table()?;
358358+359359+ {
360360+ let state_c = state.clone();
361361+ let metatable_c = metatable.clone();
362362+ let constructor = lua.create_async_function(
363363+ move |lua, (collection, data): (String, Option<mlua::Value>)| {
364364+ let state = state_c.clone();
365365+ let metatable = metatable_c.clone();
366366+ async move {
367367+ let table = lua.create_table()?;
368368+369369+ // Look up schema
370370+ let lexicon = state.lexicons.get(&collection).await;
371371+ let schema_value: mlua::Value =
372372+ match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) {
373373+ Some(schema_json) => lua.to_value(schema_json)?,
374374+ None => mlua::Value::Nil,
375375+ };
376376+377377+ // Set internal fields
378378+ table.raw_set("_collection", collection.as_str())?;
379379+ table.raw_set("_uri", mlua::Value::Nil)?;
380380+ table.raw_set("_cid", mlua::Value::Nil)?;
381381+ table.raw_set("_schema", schema_value.clone())?;
382382+383383+ // Auto-set _key_type from the lexicon's record_key
384384+ match lexicon.as_ref().and_then(|l| l.record_key.as_deref()) {
385385+ Some(key) => table.raw_set("_key_type", key)?,
386386+ None => table.raw_set("_key_type", mlua::Value::Nil)?,
387387+ }
388388+ table.raw_set("_rkey", mlua::Value::Nil)?;
389389+390390+ // Copy fields from data if provided
391391+ if let Some(mlua::Value::Table(data_table)) = data {
392392+ for pair in data_table.pairs::<mlua::Value, mlua::Value>() {
393393+ let (k, v) = pair?;
394394+ table.raw_set(k, v)?;
395395+ }
396396+ }
397397+398398+ // Populate defaults from schema
399399+ if let mlua::Value::Table(ref schema_table) = schema_value {
400400+ populate_defaults(&lua, &table, schema_table)?;
401401+ }
402402+403403+ table.set_metatable(Some(metatable))?;
404404+ Ok(table)
405405+ }
406406+ },
407407+ )?;
408408+ record_table.set("new", constructor)?;
409409+ }
410410+411411+ // -- Static methods --
412412+413413+ // Record.save_all(records)
414414+ {
415415+ let state = state.clone();
416416+ let claims = claims.clone();
417417+ let session = session.clone();
418418+ let save_all_fn =
419419+ lua.create_async_function(move |lua, records_table: mlua::Table| {
420420+ let state = state.clone();
421421+ let claims = claims.clone();
422422+ let session = session.clone();
423423+ async move {
424424+ // Extract save data from each record (sync)
425425+ type SaveItem = (mlua::Table, String, Option<String>, Option<String>, Value);
426426+ let mut save_items: Vec<SaveItem> = Vec::new();
427427+428428+ for pair in records_table.sequence_values::<mlua::Table>() {
429429+ let record_table = pair?;
430430+ let collection: String = record_table.raw_get("_collection")?;
431431+ let existing_uri: Option<String> = record_table.raw_get("_uri")?;
432432+ let rkey: Option<String> = record_table.raw_get("_rkey")?;
433433+434434+ // Validate
435435+ let schema: mlua::Value = record_table.raw_get("_schema")?;
436436+ if let mlua::Value::Table(ref schema_table) = schema {
437437+ validate_required_fields(&record_table, schema_table)?;
438438+ }
439439+440440+ let data = extract_record_data(&lua, &record_table, &collection)?;
441441+ save_items.push((record_table, collection, existing_uri, rkey, data));
442442+ }
443443+444444+ // Parallel PDS calls
445445+ let futs = save_items.iter().map(|(_, collection, existing_uri, rkey, data)| {
446446+ let state = state.clone();
447447+ let claims = claims.clone();
448448+ let session = session.clone();
449449+ let collection = collection.clone();
450450+ let existing_uri = existing_uri.clone();
451451+ let rkey = rkey.clone();
452452+ let data = data.clone();
453453+ async move {
454454+ if let Some(ref uri) = existing_uri {
455455+ let rkey = uri
456456+ .split('/')
457457+ .next_back()
458458+ .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))?
459459+ .to_string();
460460+461461+ let pds_body = json!({
462462+ "repo": claims.did(),
463463+ "collection": collection,
464464+ "rkey": rkey,
465465+ "record": data,
466466+ });
467467+468468+ let resp = repo::pds_post_json_raw(
469469+ &state,
470470+ &session,
471471+ "com.atproto.repo.putRecord",
472472+ &pds_body,
473473+ )
474474+ .await
475475+ .map_err(|e| {
476476+ mlua::Error::runtime(format!("PDS putRecord failed: {e}"))
477477+ })?;
478478+479479+ if !resp.status().is_success() {
480480+ let status = resp.status();
481481+ let body = resp.text().await.unwrap_or_default();
482482+ return Err(mlua::Error::runtime(format!(
483483+ "PDS putRecord returned {status}: {body}"
484484+ )));
485485+ }
486486+487487+ let bytes = resp.bytes().await.map_err(|e| {
488488+ mlua::Error::runtime(format!(
489489+ "failed to read PDS response: {e}"
490490+ ))
491491+ })?;
492492+ let result: Value = serde_json::from_slice(&bytes).map_err(
493493+ |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")),
494494+ )?;
495495+496496+ let cid = result
497497+ .get("cid")
498498+ .and_then(|v| v.as_str())
499499+ .unwrap_or_default();
500500+ let _ = sqlx::query(
501501+ r#"INSERT INTO records (uri, did, collection, rkey, record, cid)
502502+ VALUES ($1, $2, $3, $4, $5, $6)
503503+ ON CONFLICT (uri) DO UPDATE
504504+ SET record = EXCLUDED.record,
505505+ cid = EXCLUDED.cid,
506506+ indexed_at = NOW()"#,
507507+ )
508508+ .bind(uri.as_str())
509509+ .bind(claims.did())
510510+ .bind(&collection)
511511+ .bind(&rkey)
512512+ .bind(&data)
513513+ .bind(cid)
514514+ .execute(&state.db)
515515+ .await;
516516+517517+ Ok(result)
518518+ } else {
519519+ let mut pds_body = json!({
520520+ "repo": claims.did(),
521521+ "collection": collection,
522522+ "record": data,
523523+ });
524524+ if let Some(ref rkey) = rkey {
525525+ pds_body["rkey"] = json!(rkey);
526526+ }
527527+528528+ let resp = repo::pds_post_json_raw(
529529+ &state,
530530+ &session,
531531+ "com.atproto.repo.createRecord",
532532+ &pds_body,
533533+ )
534534+ .await
535535+ .map_err(|e| {
536536+ mlua::Error::runtime(format!(
537537+ "PDS createRecord failed: {e}"
538538+ ))
539539+ })?;
540540+541541+ if !resp.status().is_success() {
542542+ let status = resp.status();
543543+ let body = resp.text().await.unwrap_or_default();
544544+ return Err(mlua::Error::runtime(format!(
545545+ "PDS createRecord returned {status}: {body}"
546546+ )));
547547+ }
548548+549549+ let bytes = resp.bytes().await.map_err(|e| {
550550+ mlua::Error::runtime(format!(
551551+ "failed to read PDS response: {e}"
552552+ ))
553553+ })?;
554554+ let result: Value = serde_json::from_slice(&bytes).map_err(
555555+ |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")),
556556+ )?;
557557+558558+ if let (Some(uri), Some(cid)) = (
559559+ result.get("uri").and_then(|v| v.as_str()),
560560+ result.get("cid").and_then(|v| v.as_str()),
561561+ ) {
562562+ let rkey =
563563+ uri.split('/').next_back().unwrap_or_default();
564564+ let _ = sqlx::query(
565565+ r#"INSERT INTO records (uri, did, collection, rkey, record, cid)
566566+ VALUES ($1, $2, $3, $4, $5, $6)
567567+ ON CONFLICT (uri) DO UPDATE
568568+ SET record = EXCLUDED.record,
569569+ cid = EXCLUDED.cid"#,
570570+ )
571571+ .bind(uri)
572572+ .bind(claims.did())
573573+ .bind(&collection)
574574+ .bind(rkey)
575575+ .bind(&data)
576576+ .bind(cid)
577577+ .execute(&state.db)
578578+ .await;
579579+ }
580580+581581+ Ok(result)
582582+ }
583583+ }
584584+ });
585585+586586+ let results = try_join_all(futs).await?;
587587+588588+ // Write back _uri and _cid (sync)
589589+ for (i, (record_table, _, _, _, _)) in save_items.iter().enumerate() {
590590+ if let Some(result) = results.get(i) {
591591+ if let Some(uri) = result.get("uri").and_then(|v| v.as_str()) {
592592+ record_table.raw_set("_uri", uri.to_string())?;
593593+ }
594594+ if let Some(cid) = result.get("cid").and_then(|v| v.as_str()) {
595595+ record_table.raw_set("_cid", cid.to_string())?;
596596+ }
597597+ }
598598+ }
599599+600600+ lua.to_value(&results)
601601+ }
602602+ })?;
603603+ record_table.set("save_all", save_all_fn)?;
604604+ }
605605+606606+ // Record.load(uri)
607607+ {
608608+ let state = state.clone();
609609+ let metatable_c = metatable.clone();
610610+ let load_fn = lua.create_async_function(move |lua, uri: String| {
611611+ let state = state.clone();
612612+ let metatable = metatable_c.clone();
613613+ async move {
614614+ let row: Option<(String, Value, String)> =
615615+ sqlx::query_as("SELECT collection, record, cid FROM records WHERE uri = $1")
616616+ .bind(&uri)
617617+ .fetch_optional(&state.db)
618618+ .await
619619+ .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?;
620620+621621+ match row {
622622+ Some((collection, record, cid)) => {
623623+ let table = lua.create_table()?;
624624+625625+ // Look up schema
626626+ let lexicon = state.lexicons.get(&collection).await;
627627+ let schema_value: mlua::Value =
628628+ match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) {
629629+ Some(schema_json) => lua.to_value(schema_json)?,
630630+ None => mlua::Value::Nil,
631631+ };
632632+633633+ table.raw_set("_collection", collection.as_str())?;
634634+ table.raw_set("_uri", uri.as_str())?;
635635+ table.raw_set("_cid", cid.as_str())?;
636636+ table.raw_set("_schema", schema_value)?;
637637+ table.raw_set("_key_type", mlua::Value::Nil)?;
638638+ table.raw_set("_rkey", mlua::Value::Nil)?;
639639+640640+ // Copy record fields
641641+ if let Some(obj) = record.as_object() {
642642+ for (k, v) in obj {
643643+ if k == "$type" {
644644+ continue;
645645+ }
646646+ let lua_val: mlua::Value = lua.to_value(v)?;
647647+ table.raw_set(k.as_str(), lua_val)?;
648648+ }
649649+ }
650650+651651+ table.set_metatable(Some(metatable))?;
652652+ Ok(mlua::Value::Table(table))
653653+ }
654654+ None => Ok(mlua::Value::Nil),
655655+ }
656656+ }
657657+ })?;
658658+ record_table.set("load", load_fn)?;
659659+ }
660660+661661+ // Record.load_all(uris)
662662+ {
663663+ let state = state;
664664+ let metatable_c = metatable;
665665+ let load_all_fn = lua.create_async_function(move |lua, uris_table: mlua::Table| {
666666+ let state = state.clone();
667667+ let metatable = metatable_c.clone();
668668+ async move {
669669+ let uris: Vec<String> = lua.from_value(mlua::Value::Table(uris_table))?;
670670+671671+ let futs = uris.iter().map(|uri| {
672672+ let state = state.clone();
673673+ let uri = uri.clone();
674674+ async move {
675675+ let row: Option<(String, Value, String)> = sqlx::query_as(
676676+ "SELECT collection, record, cid FROM records WHERE uri = $1",
677677+ )
678678+ .bind(&uri)
679679+ .fetch_optional(&state.db)
680680+ .await
681681+ .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?;
682682+683683+ let result: Result<_, mlua::Error> =
684684+ Ok(row.map(|(collection, record, cid)| (uri, collection, record, cid)));
685685+ result
686686+ }
687687+ });
688688+689689+ let results: Vec<Option<(String, String, Value, String)>> =
690690+ try_join_all(futs).await?;
691691+692692+ let out = lua.create_table()?;
693693+ for (i, item) in results.into_iter().enumerate() {
694694+ match item {
695695+ Some((uri, collection, record, cid)) => {
696696+ let table = lua.create_table()?;
697697+698698+ let lexicon = state.lexicons.get(&collection).await;
699699+ let schema_value: mlua::Value =
700700+ match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) {
701701+ Some(schema_json) => lua.to_value(schema_json)?,
702702+ None => mlua::Value::Nil,
703703+ };
704704+705705+ table.raw_set("_collection", collection.as_str())?;
706706+ table.raw_set("_uri", uri.as_str())?;
707707+ table.raw_set("_cid", cid.as_str())?;
708708+ table.raw_set("_schema", schema_value)?;
709709+ table.raw_set("_key_type", mlua::Value::Nil)?;
710710+ table.raw_set("_rkey", mlua::Value::Nil)?;
711711+712712+ if let Some(obj) = record.as_object() {
713713+ for (k, v) in obj {
714714+ if k == "$type" {
715715+ continue;
716716+ }
717717+ let lua_val: mlua::Value = lua.to_value(v)?;
718718+ table.raw_set(k.as_str(), lua_val)?;
719719+ }
720720+ }
721721+722722+ table.set_metatable(Some(metatable.clone()))?;
723723+ out.raw_set(i + 1, table)?;
724724+ }
725725+ None => {
726726+ out.raw_set(i + 1, mlua::Value::Nil)?;
727727+ }
728728+ }
729729+ }
730730+731731+ Ok(mlua::Value::Table(out))
732732+ }
733733+ })?;
734734+ record_table.set("load_all", load_all_fn)?;
735735+ }
736736+737737+ // -- Make Record callable via __call metamethod --
738738+ let record_mt = lua.create_table()?;
739739+ {
740740+ let new_fn: mlua::Function = record_table.get("new")?;
741741+ let call_fn =
742742+ lua.create_async_function(
743743+ move |_lua,
744744+ (_self_table, collection, data): (
745745+ mlua::Table,
746746+ String,
747747+ Option<mlua::Value>,
748748+ )| {
749749+ let new_fn = new_fn.clone();
750750+ async move {
751751+ let result: mlua::Table = new_fn.call_async((collection, data)).await?;
752752+ Ok(result)
753753+ }
754754+ },
755755+ )?;
756756+ record_mt.set("__call", call_fn)?;
757757+ }
758758+ record_table.set_metatable(Some(record_mt))?;
759759+760760+ lua.globals().set("Record", record_table)?;
761761+ Ok(())
762762+}
763763+764764+/// Check that all required fields (per schema) are present and non-nil.
765765+fn validate_required_fields(table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> {
766766+ let required: Option<mlua::Table> = schema.raw_get("required")?;
767767+ if let Some(required) = required {
768768+ for pair in required.sequence_values::<String>() {
769769+ let field = pair?;
770770+ let val: mlua::Value = table.raw_get(field.as_str())?;
771771+ if val.is_nil() {
772772+ return Err(mlua::Error::runtime(format!(
773773+ "missing required field '{field}'"
774774+ )));
775775+ }
776776+ }
777777+ }
778778+ Ok(())
779779+}
780780+781781+/// Set missing fields from schema property defaults.
782782+fn populate_defaults(lua: &Lua, table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> {
783783+ let properties: Option<mlua::Table> = schema.raw_get("properties")?;
784784+ if let Some(properties) = properties {
785785+ for pair in properties.pairs::<String, mlua::Table>() {
786786+ let (key, prop_def) = pair?;
787787+ // Skip internal fields
788788+ if key.starts_with('_') {
789789+ continue;
790790+ }
791791+ let existing: mlua::Value = table.raw_get(key.as_str())?;
792792+ if existing.is_nil() {
793793+ let default: mlua::Value = prop_def.raw_get("default")?;
794794+ if !default.is_nil() {
795795+ table.raw_set(key.as_str(), lua.to_value(&default)?)?;
796796+ }
797797+ }
798798+ }
799799+ }
800800+ Ok(())
801801+}
802802+803803+/// Serialize a Record table to serde_json::Value, stripping _-prefixed keys,
804804+/// filtering to only schema-defined properties, and injecting $type.
805805+fn extract_record_data(lua: &Lua, table: &mlua::Table, collection: &str) -> LuaResult<Value> {
806806+ // Build the set of allowed property names from the schema (if available).
807807+ // When a schema is present, only fields listed in `properties` are included.
808808+ let schema: mlua::Value = table.raw_get("_schema")?;
809809+ let allowed: Option<Vec<String>> = if let mlua::Value::Table(ref schema_table) = schema {
810810+ let properties: Option<mlua::Table> = schema_table.raw_get("properties")?;
811811+ properties.map(|props| {
812812+ props
813813+ .pairs::<String, mlua::Value>()
814814+ .filter_map(|pair| pair.ok().map(|(k, _)| k))
815815+ .collect()
816816+ })
817817+ } else {
818818+ None
819819+ };
820820+821821+ let tmp = lua.create_table()?;
822822+ for pair in table.pairs::<String, mlua::Value>() {
823823+ let (k, v) = pair?;
824824+ if k.starts_with('_') {
825825+ continue;
826826+ }
827827+ if let Some(ref keys) = allowed
828828+ && !keys.iter().any(|a| a == &k)
829829+ {
830830+ continue;
831831+ }
832832+ tmp.raw_set(k, v)?;
833833+ }
834834+835835+ let mut data: Value = lua.from_value(mlua::Value::Table(tmp))?;
836836+ if let Some(obj) = data.as_object_mut() {
837837+ obj.insert("$type".to_string(), json!(collection));
838838+ }
839839+ Ok(data)
840840+}
+146
src/lua/sandbox.rs
···11+use mlua::{Lua, Result as LuaResult};
22+33+use super::tid::generate_tid;
44+55+const INSTRUCTION_LIMIT: u32 = 1_000_000;
66+77+/// Create a fresh sandboxed Lua VM.
88+///
99+/// - Dangerous globals (`os`, `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`) are removed.
1010+/// - An instruction-count hook prevents infinite loops.
1111+/// - Utility globals `now()` and `log()` are injected.
1212+pub fn create_sandbox() -> LuaResult<Lua> {
1313+ let lua = Lua::new();
1414+1515+ // Remove dangerous globals
1616+ let globals = lua.globals();
1717+ for name in &[
1818+ "os",
1919+ "io",
2020+ "debug",
2121+ "package",
2222+ "require",
2323+ "dofile",
2424+ "loadfile",
2525+ "load",
2626+ "collectgarbage",
2727+ ] {
2828+ globals.raw_set(*name, mlua::Value::Nil)?;
2929+ }
3030+3131+ // Instruction limit to prevent infinite loops
3232+ lua.set_hook(
3333+ mlua::HookTriggers::new().every_nth_instruction(INSTRUCTION_LIMIT),
3434+ |_lua, _debug| Err(mlua::Error::runtime("script exceeded execution limit")),
3535+ )?;
3636+3737+ // Utility: now() returns UTC ISO 8601 string
3838+ let now_fn = lua.create_function(|_, ()| Ok(chrono::Utc::now().to_rfc3339()))?;
3939+ globals.set("now", now_fn)?;
4040+4141+ // Utility: log(message) logs via tracing::debug
4242+ let log_fn = lua.create_function(|_, msg: String| {
4343+ tracing::debug!(lua_log = %msg, "lua script log");
4444+ Ok(())
4545+ })?;
4646+ globals.set("log", log_fn)?;
4747+4848+ // Utility: TID() returns a fresh AT Protocol TID string
4949+ let tid_fn = lua.create_function(|_, ()| Ok(generate_tid()))?;
5050+ globals.set("TID", tid_fn)?;
5151+5252+ Ok(lua)
5353+}
5454+5555+/// Validate that a script compiles and defines a `handle` function.
5656+pub fn validate_script(source: &str) -> Result<(), String> {
5757+ let lua = create_sandbox().map_err(|e| format!("failed to create Lua VM: {e}"))?;
5858+ lua.load(source)
5959+ .exec()
6060+ .map_err(|e| format!("script compilation failed: {e}"))?;
6161+6262+ let globals = lua.globals();
6363+ match globals.get::<mlua::Function>("handle") {
6464+ Ok(_) => Ok(()),
6565+ Err(_) => Err("script must define a handle() function".into()),
6666+ }
6767+}
6868+6969+#[cfg(test)]
7070+mod tests {
7171+ use super::*;
7272+7373+ #[test]
7474+ fn sandbox_removes_dangerous_globals() {
7575+ let lua = create_sandbox().unwrap();
7676+ let globals = lua.globals();
7777+ assert!(globals.get::<mlua::Value>("os").unwrap().is_nil());
7878+ assert!(globals.get::<mlua::Value>("io").unwrap().is_nil());
7979+ assert!(globals.get::<mlua::Value>("debug").unwrap().is_nil());
8080+ assert!(globals.get::<mlua::Value>("package").unwrap().is_nil());
8181+ assert!(globals.get::<mlua::Value>("require").unwrap().is_nil());
8282+ }
8383+8484+ #[test]
8585+ fn sandbox_provides_now() {
8686+ let lua = create_sandbox().unwrap();
8787+ let result: String = lua.load("return now()").eval().unwrap();
8888+ assert!(result.contains("T")); // ISO 8601 format
8989+ }
9090+9191+ #[test]
9292+ fn sandbox_provides_log() {
9393+ let lua = create_sandbox().unwrap();
9494+ lua.load(r#"log("test message")"#).exec().unwrap();
9595+ }
9696+9797+ #[test]
9898+ fn sandbox_provides_tid() {
9999+ let lua = create_sandbox().unwrap();
100100+ let result: String = lua.load("return TID()").eval().unwrap();
101101+ assert_eq!(result.len(), 13);
102102+ let valid = "234567abcdefghijklmnopqrstuvwxyz";
103103+ for ch in result.chars() {
104104+ assert!(valid.contains(ch), "invalid char '{ch}' in TID");
105105+ }
106106+ }
107107+108108+ #[test]
109109+ fn sandbox_tid_returns_unique_values() {
110110+ let lua = create_sandbox().unwrap();
111111+ let a: String = lua.load("return TID()").eval().unwrap();
112112+ let b: String = lua.load("return TID()").eval().unwrap();
113113+ assert_ne!(a, b);
114114+ }
115115+116116+ #[test]
117117+ fn sandbox_kills_infinite_loop() {
118118+ let lua = create_sandbox().unwrap();
119119+ let result = lua.load("while true do end").exec();
120120+ assert!(result.is_err());
121121+ let err = result.unwrap_err().to_string();
122122+ assert!(
123123+ err.contains("execution limit"),
124124+ "expected execution limit error, got: {err}"
125125+ );
126126+ }
127127+128128+ #[test]
129129+ fn validate_script_accepts_valid() {
130130+ let result = validate_script("function handle() return {} end");
131131+ assert!(result.is_ok());
132132+ }
133133+134134+ #[test]
135135+ fn validate_script_rejects_missing_handle() {
136136+ let result = validate_script("function other() return {} end");
137137+ assert!(result.is_err());
138138+ assert!(result.unwrap_err().contains("handle"));
139139+ }
140140+141141+ #[test]
142142+ fn validate_script_rejects_syntax_error() {
143143+ let result = validate_script("function handle(");
144144+ assert!(result.is_err());
145145+ }
146146+}
+76
src/lua/tid.rs
···11+use std::time::{SystemTime, UNIX_EPOCH};
22+33+/// Base32-sortstring alphabet used by AT Protocol TIDs.
44+const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
55+66+/// Generate a TID (timestamp identifier) compatible with the AT Protocol spec.
77+///
88+/// Layout: 64-bit value = `(microsecond_timestamp << 10) | random_10bit_clock_id`
99+/// Encoded as a 13-character base32-sortstring.
1010+pub fn generate_tid() -> String {
1111+ let us = SystemTime::now()
1212+ .duration_since(UNIX_EPOCH)
1313+ .expect("system clock before UNIX epoch")
1414+ .as_micros() as u64;
1515+1616+ // 10-bit random clock ID from UUID v4 bytes
1717+ let rand_bytes = uuid::Uuid::new_v4();
1818+ let clock_id = u16::from_le_bytes([rand_bytes.as_bytes()[0], rand_bytes.as_bytes()[1]]) & 0x3FF;
1919+2020+ let val = (us << 10) | clock_id as u64;
2121+ encode_base32_sort(val)
2222+}
2323+2424+/// Encode a u64 into a 13-character base32-sortstring.
2525+fn encode_base32_sort(mut val: u64) -> String {
2626+ let mut buf = [0u8; 13];
2727+ for i in (0..13).rev() {
2828+ buf[i] = BASE32_SORT[(val & 0x1F) as usize];
2929+ val >>= 5;
3030+ }
3131+ // SAFETY: all bytes come from BASE32_SORT which is ASCII
3232+ String::from_utf8(buf.to_vec()).unwrap()
3333+}
3434+3535+#[cfg(test)]
3636+mod tests {
3737+ use super::*;
3838+3939+ #[test]
4040+ fn tid_is_13_chars() {
4141+ let tid = generate_tid();
4242+ assert_eq!(tid.len(), 13, "TID should be 13 characters, got: {tid}");
4343+ }
4444+4545+ #[test]
4646+ fn tid_uses_valid_charset() {
4747+ let tid = generate_tid();
4848+ let valid = "234567abcdefghijklmnopqrstuvwxyz";
4949+ for ch in tid.chars() {
5050+ assert!(valid.contains(ch), "invalid character '{ch}' in TID {tid}");
5151+ }
5252+ }
5353+5454+ #[test]
5555+ fn tids_are_unique() {
5656+ let a = generate_tid();
5757+ let b = generate_tid();
5858+ assert_ne!(a, b, "two TIDs should differ");
5959+ }
6060+6161+ #[test]
6262+ fn tids_are_sortable() {
6363+ // TIDs generated later should sort after earlier ones
6464+ let a = generate_tid();
6565+ std::thread::sleep(std::time::Duration::from_millis(2));
6666+ let b = generate_tid();
6767+ assert!(b > a, "later TID '{b}' should sort after earlier TID '{a}'");
6868+ }
6969+7070+ #[test]
7171+ fn encode_base32_sort_known_value() {
7272+ // Zero should encode to all '2's (the first character in the alphabet)
7373+ let result = encode_base32_sort(0);
7474+ assert_eq!(result, "2222222222222");
7575+ }
7676+}
···11-use crate::error::AppError;
22-33-/// Extract the DID from an AT URI (at://did/collection/rkey).
44-#[allow(dead_code)]
55-pub(crate) fn parse_did_from_at_uri(uri: &str) -> Result<String, AppError> {
66- let stripped = uri
77- .strip_prefix("at://")
88- .ok_or_else(|| AppError::Internal("AT URI must start with at://".into()))?;
99-1010- stripped
1111- .split('/')
1212- .next()
1313- .map(|s| s.to_string())
1414- .ok_or_else(|| AppError::Internal("invalid AT URI".into()))
1515-}
1616-1717-#[cfg(test)]
1818-mod tests {
1919- use super::*;
2020-2121- #[test]
2222- fn parse_did_from_valid_at_uri() {
2323- let did = parse_did_from_at_uri("at://did:plc:abc123/app.bsky.feed.post/3k2bqxyz").unwrap();
2424- assert_eq!(did, "did:plc:abc123");
2525- }
2626-2727- #[test]
2828- fn parse_did_from_uri_with_no_rkey() {
2929- let did = parse_did_from_at_uri("at://did:plc:abc123/collection").unwrap();
3030- assert_eq!(did, "did:plc:abc123");
3131- }
3232-3333- #[test]
3434- fn parse_did_from_did_web_uri() {
3535- let did = parse_did_from_at_uri("at://did:web:example.com/collection/rkey").unwrap();
3636- assert_eq!(did, "did:web:example.com");
3737- }
3838-3939- #[test]
4040- fn parse_did_from_uri_missing_prefix() {
4141- let result = parse_did_from_at_uri("did:plc:abc123/collection/rkey");
4242- assert!(result.is_err());
4343- }
4444-}
-111
src/repo/media.rs
···11-use serde_json::{Value, json};
22-33-/// Walk `media[]` and add a `url` field to each blob so the frontend can
44-/// display images directly.
55-#[allow(dead_code)]
66-pub(crate) fn enrich_media_blobs(record: &mut Value, pds: &str, did: &str) {
77- let media = match record.get_mut("media").and_then(|m| m.as_array_mut()) {
88- Some(arr) => arr,
99- None => return,
1010- };
1111-1212- let pds_base = pds.trim_end_matches('/');
1313-1414- for item in media.iter_mut() {
1515- let cid = item
1616- .get("blob")
1717- .and_then(|b| b.get("ref"))
1818- .and_then(|r| r.get("$link"))
1919- .and_then(|l| l.as_str())
2020- .map(|s| s.to_string());
2121-2222- if let Some(cid) = cid
2323- && let Some(blob) = item.get_mut("blob")
2424- && let Some(obj) = blob.as_object_mut()
2525- {
2626- obj.insert(
2727- "url".to_string(),
2828- json!(format!(
2929- "{pds_base}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}"
3030- )),
3131- );
3232- }
3333- }
3434-}
3535-3636-#[cfg(test)]
3737-mod tests {
3838- use super::*;
3939-4040- #[test]
4141- fn enrich_media_adds_url() {
4242- let mut record = json!({
4343- "media": [{
4444- "blob": {
4545- "ref": { "$link": "bafyreiabc" },
4646- "mimeType": "image/jpeg",
4747- "size": 1024
4848- }
4949- }]
5050- });
5151-5252- enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
5353-5454- let url = record["media"][0]["blob"]["url"].as_str().unwrap();
5555- assert_eq!(
5656- url,
5757- "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreiabc"
5858- );
5959- }
6060-6161- #[test]
6262- fn enrich_media_noop_without_media() {
6363- let mut record = json!({"title": "test"});
6464- enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
6565- assert!(record.get("media").is_none());
6666- }
6767-6868- #[test]
6969- fn enrich_media_skips_items_without_ref() {
7070- let mut record = json!({
7171- "media": [{
7272- "blob": { "mimeType": "image/png" }
7373- }]
7474- });
7575-7676- enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
7777- assert!(record["media"][0]["blob"].get("url").is_none());
7878- }
7979-8080- #[test]
8181- fn enrich_media_handles_multiple_items() {
8282- let mut record = json!({
8383- "media": [
8484- { "blob": { "ref": { "$link": "cid1" } } },
8585- { "blob": { "ref": { "$link": "cid2" } } }
8686- ]
8787- });
8888-8989- enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:x");
9090-9191- let url1 = record["media"][0]["blob"]["url"].as_str().unwrap();
9292- let url2 = record["media"][1]["blob"]["url"].as_str().unwrap();
9393- assert!(url1.contains("cid1"));
9494- assert!(url2.contains("cid2"));
9595- }
9696-9797- #[test]
9898- fn enrich_media_trims_trailing_slash() {
9999- let mut record = json!({
100100- "media": [{
101101- "blob": { "ref": { "$link": "bafytest" } }
102102- }]
103103- });
104104-105105- enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:test");
106106-107107- let url = record["media"][0]["blob"]["url"].as_str().unwrap();
108108- assert!(url.starts_with("https://pds.example.com/xrpc/"));
109109- assert!(!url.contains("//xrpc"));
110110- }
111111-}
-4
src/repo/mod.rs
···11-mod at_uri;
21mod dpop;
33-mod media;
42mod pds;
53pub(crate) mod session;
64mod upload_blob;
7588-pub(crate) use at_uri::parse_did_from_at_uri;
99-pub(crate) use media::enrich_media_blobs;
106pub(crate) use pds::{forward_pds_response, pds_post_json_raw};
117pub(crate) use session::{AtpSession, get_atp_session};
128pub use upload_blob::upload_blob;
···11+/** Extract Lua identifier names (variables, functions, parameters). */
22+export function parseLuaIdentifiers(source: string): Set<string> {
33+ const ids = new Set<string>();
44+ // local var, local var = ..., local var1, var2 = ...
55+ for (const m of source.matchAll(/\blocal\s+([\w,\s]+?)(?:\s*=|$)/gm)) {
66+ for (const name of m[1].split(",")) {
77+ const trimmed = name.trim();
88+ if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed);
99+ }
1010+ }
1111+ // function name(...), local function name(...)
1212+ for (const m of source.matchAll(/\bfunction\s+(\w+)\s*\(([^)]*)\)/g)) {
1313+ ids.add(m[1]);
1414+ for (const p of m[2].split(",")) {
1515+ const trimmed = p.trim();
1616+ if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed);
1717+ }
1818+ }
1919+ // for var [, var...] in/=
2020+ for (const m of source.matchAll(/\bfor\s+([\w,\s]+?)\s+in\b/g)) {
2121+ for (const name of m[1].split(",")) {
2222+ const trimmed = name.trim();
2323+ if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed);
2424+ }
2525+ }
2626+ return ids;
2727+}
2828+2929+/** Parse Lua source for `Record("collection")` variable assignments. */
3030+export function parseRecordVariables(source: string): Record<string, string> {
3131+ const map: Record<string, string> = {};
3232+ // Match: local var = Record("collection" and var = Record("collection"
3333+ const re = /(?:local\s+)?(\w+)\s*=\s*Record\(\s*"([^"]+)"/g;
3434+ let m;
3535+ while ((m = re.exec(source)) !== null) {
3636+ map[m[1]] = m[2];
3737+ }
3838+ return map;
3939+}