···66mod record;
77pub(crate) mod sandbox;
88mod tid;
99+mod xrpc_api;
9101011pub(crate) use execute::{
1112 HookEvent, execute_hook_script, execute_procedure_script, execute_query_script,
+660
src/lua/xrpc_api.rs
···11+use axum::response::Response;
22+use http_body_util::BodyExt;
33+use mlua::{Lua, LuaSerdeExt, Result as LuaResult};
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::lexicon::LexiconType;
1111+use crate::xrpc;
1212+1313+/// Convert an axum Response into a Lua table with `{ status, body }`.
1414+async fn response_to_lua_table(lua: &Lua, response: Response) -> LuaResult<mlua::Table> {
1515+ let status = response.status().as_u16();
1616+ let body_bytes = response
1717+ .into_body()
1818+ .collect()
1919+ .await
2020+ .map_err(|e| mlua::Error::runtime(format!("failed to read response body: {e}")))?
2121+ .to_bytes();
2222+ let body = String::from_utf8_lossy(&body_bytes).to_string();
2323+2424+ let table = lua.create_table()?;
2525+ table.set("status", status)?;
2626+ table.set("body", body)?;
2727+ Ok(table)
2828+}
2929+3030+/// Convert an Option<mlua::Table> of params into a HashMap<String, Value>.
3131+fn lua_table_to_params(lua: &Lua, table: Option<mlua::Table>) -> LuaResult<HashMap<String, Value>> {
3232+ match table {
3333+ Some(t) => {
3434+ let value: Value = lua.from_value(mlua::Value::Table(t))?;
3535+ match value {
3636+ Value::Object(map) => Ok(map.into_iter().collect()),
3737+ _ => Ok(HashMap::new()),
3838+ }
3939+ }
4040+ None => Ok(HashMap::new()),
4141+ }
4242+}
4343+4444+pub fn register_xrpc_api(
4545+ lua: &Lua,
4646+ state: Arc<AppState>,
4747+ caller_did: Option<String>,
4848+) -> LuaResult<()> {
4949+ let xrpc_table = lua.create_table()?;
5050+5151+ // xrpc.query(method, params?)
5252+ {
5353+ let state = state.clone();
5454+ let caller_did = caller_did.clone();
5555+ let func = lua.create_async_function(
5656+ move |lua, (method, params): (String, Option<mlua::Table>)| {
5757+ let state = state.clone();
5858+ let caller_did = caller_did.clone();
5959+ async move {
6060+ let mut params = lua_table_to_params(&lua, params)?;
6161+ let claims = caller_did.map(Claims::internal);
6262+6363+ let response =
6464+ execute_local_query(&state, &method, &mut params, claims.as_ref())
6565+ .await
6666+ .map_err(|e| mlua::Error::runtime(format!("xrpc query failed: {e}")))?;
6767+6868+ response_to_lua_table(&lua, response).await
6969+ }
7070+ },
7171+ )?;
7272+ xrpc_table.set("query", func)?;
7373+ }
7474+7575+ // xrpc.procedure(method, input, params?)
7676+ {
7777+ let state = state.clone();
7878+ let caller_did = caller_did.clone();
7979+ let func = lua.create_async_function(
8080+ move |lua, (method, input, params): (String, mlua::Value, Option<mlua::Table>)| {
8181+ let state = state.clone();
8282+ let caller_did = caller_did.clone();
8383+ async move {
8484+ let mut params = lua_table_to_params(&lua, params)?;
8585+ let input: Value = lua.from_value(input)?;
8686+ let claims = caller_did.clone().map(Claims::internal).ok_or_else(|| {
8787+ mlua::Error::runtime(
8888+ "xrpc.procedure requires authentication (no caller_did)",
8989+ )
9090+ })?;
9191+9292+ let response =
9393+ execute_local_procedure(&state, &method, &claims, &input, &mut params)
9494+ .await
9595+ .map_err(|e| {
9696+ mlua::Error::runtime(format!("xrpc procedure failed: {e}"))
9797+ })?;
9898+9999+ response_to_lua_table(&lua, response).await
100100+ }
101101+ },
102102+ )?;
103103+ xrpc_table.set("procedure", func)?;
104104+ }
105105+106106+ lua.globals().set("xrpc", xrpc_table)?;
107107+ Ok(())
108108+}
109109+110110+/// Execute a query XRPC — local handler if known, proxy if not.
111111+async fn execute_local_query(
112112+ state: &AppState,
113113+ method: &str,
114114+ params: &mut HashMap<String, Value>,
115115+ claims: Option<&Claims>,
116116+) -> Result<Response, crate::error::AppError> {
117117+ let lexicon = state.lexicons.get(method).await;
118118+119119+ match lexicon {
120120+ Some(lex) => {
121121+ if lex.lexicon_type != LexiconType::Query {
122122+ return Err(crate::error::AppError::BadRequest(format!(
123123+ "{method} is not a query endpoint"
124124+ )));
125125+ }
126126+ if let Some(ref param_schema) = lex.parameters {
127127+ xrpc::coerce_params(params, param_schema);
128128+ }
129129+ xrpc::query::handle_query(state, method, params, &lex, claims).await
130130+ }
131131+ None => {
132132+ let query_string = params_to_query_string(params);
133133+ xrpc::proxy_to_authority(state, method, &query_string, None).await
134134+ }
135135+ }
136136+}
137137+138138+/// Build a query string from a params HashMap (used by proxy path).
139139+fn params_to_query_string(params: &HashMap<String, Value>) -> String {
140140+ params
141141+ .iter()
142142+ .map(|(k, v)| {
143143+ let val = match v {
144144+ Value::String(s) => s.clone(),
145145+ other => other.to_string(),
146146+ };
147147+ format!("{}={}", urlencoding::encode(k), urlencoding::encode(&val))
148148+ })
149149+ .collect::<Vec<_>>()
150150+ .join("&")
151151+}
152152+153153+/// Execute a procedure XRPC — local handler if known, proxy if not.
154154+async fn execute_local_procedure(
155155+ state: &AppState,
156156+ method: &str,
157157+ claims: &Claims,
158158+ input: &Value,
159159+ params: &mut HashMap<String, Value>,
160160+) -> Result<Response, crate::error::AppError> {
161161+ let lexicon = state.lexicons.get(method).await;
162162+163163+ match lexicon {
164164+ Some(lex) => {
165165+ if lex.lexicon_type != LexiconType::Procedure {
166166+ return Err(crate::error::AppError::BadRequest(format!(
167167+ "{method} is not a procedure endpoint"
168168+ )));
169169+ }
170170+ if let Some(ref param_schema) = lex.parameters {
171171+ xrpc::coerce_params(params, param_schema);
172172+ }
173173+ xrpc::procedure::handle_procedure(state, method, claims, input, params, &lex).await
174174+ }
175175+ None => {
176176+ let query_string = params_to_query_string(params);
177177+ xrpc::proxy_to_authority(state, method, &query_string, Some(input)).await
178178+ }
179179+ }
180180+}
181181+182182+#[cfg(test)]
183183+mod tests {
184184+ use super::*;
185185+ use crate::config::Config;
186186+ use crate::db::DatabaseBackend;
187187+ use crate::lexicon::{LexiconRegistry, LexiconType, ParsedLexicon, ProcedureAction};
188188+ use crate::lua::sandbox;
189189+ use mlua::LuaSerdeExt;
190190+ use serde_json::json;
191191+ use tokio::sync::watch;
192192+193193+ fn test_state() -> AppState {
194194+ let config = Config {
195195+ host: "127.0.0.1".into(),
196196+ port: 3000,
197197+ database_url: String::new(),
198198+ database_backend: DatabaseBackend::Sqlite,
199199+ public_url: String::new(),
200200+ session_secret: "test-secret".into(),
201201+ jetstream_url: String::new(),
202202+ relay_url: String::new(),
203203+ plc_url: String::new(),
204204+ static_dir: String::new(),
205205+ event_log_retention_days: 30,
206206+ app_name: None,
207207+ logo_uri: None,
208208+ tos_uri: None,
209209+ policy_uri: None,
210210+ token_encryption_key: None,
211211+ default_rate_limit_capacity: 100,
212212+ default_rate_limit_refill_rate: 2.0,
213213+ };
214214+ let (tx, _) = watch::channel(vec![]);
215215+ let (labeler_tx, _) = watch::channel(());
216216+ sqlx::any::install_default_drivers();
217217+ let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap();
218218+ let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default());
219219+ let did_resolver = atrium_identity::did::CommonDidResolver::new(
220220+ atrium_identity::did::CommonDidResolverConfig {
221221+ plc_directory_url: "https://plc.directory".into(),
222222+ http_client: std::sync::Arc::clone(&atrium_http),
223223+ },
224224+ );
225225+ let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new(
226226+ atrium_identity::handle::AtprotoHandleResolverConfig {
227227+ dns_txt_resolver: crate::dns::NativeDnsResolver::new(),
228228+ http_client: atrium_http,
229229+ },
230230+ );
231231+ let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig {
232232+ client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata {
233233+ redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]),
234234+ scopes: Some(vec![atrium_oauth::Scope::Known(
235235+ atrium_oauth::KnownScope::Atproto,
236236+ )]),
237237+ },
238238+ keys: None,
239239+ state_store: crate::auth::oauth_store::DbStateStore::new(
240240+ test_db.clone(),
241241+ DatabaseBackend::Sqlite,
242242+ ),
243243+ session_store: crate::auth::oauth_store::DbSessionStore::new(
244244+ test_db.clone(),
245245+ DatabaseBackend::Sqlite,
246246+ ),
247247+ resolver: atrium_oauth::OAuthResolverConfig {
248248+ did_resolver,
249249+ handle_resolver,
250250+ authorization_server_metadata: Default::default(),
251251+ protected_resource_metadata: Default::default(),
252252+ },
253253+ })
254254+ .expect("Failed to create test OAuth client");
255255+ AppState {
256256+ config,
257257+ http: reqwest::Client::new(),
258258+ db: test_db.clone(),
259259+ db_backend: DatabaseBackend::Sqlite,
260260+ lexicons: LexiconRegistry::new(),
261261+ collections_tx: tx,
262262+ labeler_subscriptions_tx: labeler_tx,
263263+ rate_limiter: crate::rate_limit::RateLimiter::new(
264264+ false,
265265+ crate::rate_limit::RateLimitConfig {
266266+ capacity: 100,
267267+ refill_rate: 2.0,
268268+ default_query_cost: 1,
269269+ default_procedure_cost: 1,
270270+ default_proxy_cost: 1,
271271+ },
272272+ ),
273273+ oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new(
274274+ oauth,
275275+ ))),
276276+ oauth_state_store: crate::auth::oauth_store::DbStateStore::new(
277277+ test_db.clone(),
278278+ DatabaseBackend::Sqlite,
279279+ ),
280280+ cookie_key: axum_extra::extract::cookie::Key::derive_from(
281281+ b"test-secret-for-tests-only-not-production",
282282+ ),
283283+ plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()),
284284+ wasm_runtime: std::sync::Arc::new(
285285+ crate::plugin::WasmRuntime::new().expect("wasm runtime"),
286286+ ),
287287+ attestation_signer: None,
288288+ }
289289+ }
290290+291291+ fn make_query_lexicon(id: &str, script: Option<&str>) -> ParsedLexicon {
292292+ ParsedLexicon {
293293+ id: id.to_string(),
294294+ lexicon_type: LexiconType::Query,
295295+ record_key: None,
296296+ parameters: None,
297297+ input: None,
298298+ output: None,
299299+ record_schema: None,
300300+ raw: json!({}),
301301+ revision: 1,
302302+ target_collection: None,
303303+ action: ProcedureAction::Create,
304304+ script: script.map(|s| s.to_string()),
305305+ index_hook: None,
306306+ token_cost: None,
307307+ }
308308+ }
309309+310310+ fn make_procedure_lexicon(id: &str, script: Option<&str>) -> ParsedLexicon {
311311+ ParsedLexicon {
312312+ id: id.to_string(),
313313+ lexicon_type: LexiconType::Procedure,
314314+ record_key: None,
315315+ parameters: None,
316316+ input: None,
317317+ output: None,
318318+ record_schema: None,
319319+ raw: json!({}),
320320+ revision: 1,
321321+ target_collection: None,
322322+ action: ProcedureAction::Create,
323323+ script: script.map(|s| s.to_string()),
324324+ index_hook: None,
325325+ token_cost: None,
326326+ }
327327+ }
328328+329329+ // -----------------------------------------------------------------------
330330+ // Registration
331331+ // -----------------------------------------------------------------------
332332+333333+ #[tokio::test]
334334+ async fn register_xrpc_api_creates_global() {
335335+ let state = Arc::new(test_state());
336336+ let lua = sandbox::create_sandbox().unwrap();
337337+ register_xrpc_api(&lua, state, Some("did:plc:test".into())).unwrap();
338338+339339+ let xrpc: mlua::Table = lua.globals().get("xrpc").unwrap();
340340+ assert!(xrpc.get::<mlua::Function>("query").is_ok());
341341+ assert!(xrpc.get::<mlua::Function>("procedure").is_ok());
342342+ }
343343+344344+ #[tokio::test]
345345+ async fn register_xrpc_api_without_caller_did() {
346346+ let state = Arc::new(test_state());
347347+ let lua = sandbox::create_sandbox().unwrap();
348348+ register_xrpc_api(&lua, state, None).unwrap();
349349+350350+ let xrpc: mlua::Table = lua.globals().get("xrpc").unwrap();
351351+ assert!(xrpc.get::<mlua::Function>("query").is_ok());
352352+ assert!(xrpc.get::<mlua::Function>("procedure").is_ok());
353353+ }
354354+355355+ // -----------------------------------------------------------------------
356356+ // lua_table_to_params
357357+ // -----------------------------------------------------------------------
358358+359359+ #[test]
360360+ fn lua_table_to_params_none_returns_empty() {
361361+ let lua = sandbox::create_sandbox().unwrap();
362362+ let result = lua_table_to_params(&lua, None).unwrap();
363363+ assert!(result.is_empty());
364364+ }
365365+366366+ #[test]
367367+ fn lua_table_to_params_converts_string_values() {
368368+ let lua = sandbox::create_sandbox().unwrap();
369369+ let table = lua.create_table().unwrap();
370370+ table.set("handle", "user.bsky.social").unwrap();
371371+ table.set("limit", 10).unwrap();
372372+373373+ let result = lua_table_to_params(&lua, Some(table)).unwrap();
374374+ assert_eq!(result.get("handle").unwrap(), "user.bsky.social");
375375+ assert_eq!(result.get("limit").unwrap(), 10);
376376+ }
377377+378378+ // -----------------------------------------------------------------------
379379+ // params_to_query_string
380380+ // -----------------------------------------------------------------------
381381+382382+ #[test]
383383+ fn params_to_query_string_empty() {
384384+ let params = HashMap::new();
385385+ assert_eq!(params_to_query_string(¶ms), "");
386386+ }
387387+388388+ #[test]
389389+ fn params_to_query_string_encodes_values() {
390390+ let mut params = HashMap::new();
391391+ params.insert("handle".into(), Value::String("user.bsky.social".into()));
392392+ let qs = params_to_query_string(¶ms);
393393+ assert!(qs.contains("handle=user.bsky.social"));
394394+ }
395395+396396+ #[test]
397397+ fn params_to_query_string_url_encodes_special_chars() {
398398+ let mut params = HashMap::new();
399399+ params.insert(
400400+ "uri".into(),
401401+ Value::String("at://did:plc:abc/col/rkey".into()),
402402+ );
403403+ let qs = params_to_query_string(¶ms);
404404+ assert!(qs.contains("uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fcol%2Frkey"));
405405+ }
406406+407407+ // -----------------------------------------------------------------------
408408+ // execute_local_query
409409+ // -----------------------------------------------------------------------
410410+411411+ #[tokio::test]
412412+ async fn query_local_script_returns_json() {
413413+ let state = test_state();
414414+415415+ // Register a scripted query that returns a static response
416416+ let lexicon = make_query_lexicon(
417417+ "test.echo",
418418+ Some(r#"function handle() return { greeting = "hello" } end"#),
419419+ );
420420+ state.lexicons.upsert(lexicon).await;
421421+422422+ let mut params = HashMap::new();
423423+ let result = execute_local_query(&state, "test.echo", &mut params, None).await;
424424+ assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
425425+426426+ let response = result.unwrap();
427427+ assert_eq!(response.status(), 200);
428428+429429+ let body = response.into_body().collect().await.unwrap().to_bytes();
430430+ let json: Value = serde_json::from_slice(&body).unwrap();
431431+ assert_eq!(json["greeting"], "hello");
432432+ }
433433+434434+ #[tokio::test]
435435+ async fn query_local_script_receives_params() {
436436+ let state = test_state();
437437+438438+ let lexicon = make_query_lexicon(
439439+ "test.greet",
440440+ Some(r#"function handle() return { greeting = "hello " .. params.name } end"#),
441441+ );
442442+ state.lexicons.upsert(lexicon).await;
443443+444444+ let mut params = HashMap::new();
445445+ params.insert("name".into(), Value::String("world".into()));
446446+ let result = execute_local_query(&state, "test.greet", &mut params, None).await;
447447+ assert!(result.is_ok());
448448+449449+ let body = result
450450+ .unwrap()
451451+ .into_body()
452452+ .collect()
453453+ .await
454454+ .unwrap()
455455+ .to_bytes();
456456+ let json: Value = serde_json::from_slice(&body).unwrap();
457457+ assert_eq!(json["greeting"], "hello world");
458458+ }
459459+460460+ #[tokio::test]
461461+ async fn query_local_script_receives_caller_did() {
462462+ let state = test_state();
463463+464464+ let lexicon = make_query_lexicon(
465465+ "test.whoami",
466466+ Some(
467467+ r#"function handle()
468468+ return { did = caller_did or "anonymous" }
469469+ end"#,
470470+ ),
471471+ );
472472+ state.lexicons.upsert(lexicon).await;
473473+474474+ // With caller_did
475475+ let claims = Claims::internal("did:plc:testuser".into());
476476+ let mut params = HashMap::new();
477477+ let result = execute_local_query(&state, "test.whoami", &mut params, Some(&claims)).await;
478478+ assert!(result.is_ok());
479479+ let body = result
480480+ .unwrap()
481481+ .into_body()
482482+ .collect()
483483+ .await
484484+ .unwrap()
485485+ .to_bytes();
486486+ let json: Value = serde_json::from_slice(&body).unwrap();
487487+ assert_eq!(json["did"], "did:plc:testuser");
488488+489489+ // Without caller_did
490490+ let mut params = HashMap::new();
491491+ let result = execute_local_query(&state, "test.whoami", &mut params, None).await;
492492+ assert!(result.is_ok());
493493+ let body = result
494494+ .unwrap()
495495+ .into_body()
496496+ .collect()
497497+ .await
498498+ .unwrap()
499499+ .to_bytes();
500500+ let json: Value = serde_json::from_slice(&body).unwrap();
501501+ assert_eq!(json["did"], "anonymous");
502502+ }
503503+504504+ #[tokio::test]
505505+ async fn query_rejects_procedure_lexicon() {
506506+ let state = test_state();
507507+508508+ let lexicon = make_procedure_lexicon("test.create", None);
509509+ state.lexicons.upsert(lexicon).await;
510510+511511+ let mut params = HashMap::new();
512512+ let result = execute_local_query(&state, "test.create", &mut params, None).await;
513513+ assert!(result.is_err());
514514+ let err = format!("{:?}", result.unwrap_err());
515515+ assert!(err.contains("not a query endpoint"), "got: {err}");
516516+ }
517517+518518+ #[tokio::test]
519519+ async fn procedure_rejects_query_lexicon() {
520520+ let state = test_state();
521521+522522+ let lexicon = make_query_lexicon("test.echo", Some("function handle() end"));
523523+ state.lexicons.upsert(lexicon).await;
524524+525525+ let claims = Claims::internal("did:plc:test".into());
526526+ let mut params = HashMap::new();
527527+ let result =
528528+ execute_local_procedure(&state, "test.echo", &claims, &json!({}), &mut params).await;
529529+ assert!(result.is_err());
530530+ let err = format!("{:?}", result.unwrap_err());
531531+ assert!(err.contains("not a procedure endpoint"), "got: {err}");
532532+ }
533533+534534+ // -----------------------------------------------------------------------
535535+ // Lua integration: xrpc.query from within a script
536536+ // -----------------------------------------------------------------------
537537+538538+ #[tokio::test]
539539+ async fn lua_script_calls_xrpc_query() {
540540+ let state = test_state();
541541+542542+ // Register a simple query that the outer script will call
543543+ let inner_lexicon = make_query_lexicon(
544544+ "test.inner",
545545+ Some(r#"function handle() return { value = 42 } end"#),
546546+ );
547547+ state.lexicons.upsert(inner_lexicon).await;
548548+549549+ let state_arc = Arc::new(state);
550550+ let lua = sandbox::create_sandbox().unwrap();
551551+552552+ register_xrpc_api(&lua, state_arc, None).unwrap();
553553+554554+ // Script that calls xrpc.query and parses the result
555555+ lua.load(
556556+ r#"
557557+ function handle()
558558+ local resp = xrpc.query("test.inner")
559559+ local data = json.decode(resp.body)
560560+ return { status = resp.status, inner_value = data.value }
561561+ end
562562+ "#,
563563+ )
564564+ .exec()
565565+ .unwrap();
566566+567567+ // Register json global for the script
568568+ let json_table = lua.create_table().unwrap();
569569+ let decode = lua
570570+ .create_function(|lua, s: String| {
571571+ let val: Value = serde_json::from_str(&s)
572572+ .map_err(|e| mlua::Error::runtime(format!("json decode: {e}")))?;
573573+ lua.to_value(&val)
574574+ })
575575+ .unwrap();
576576+ json_table.set("decode", decode).unwrap();
577577+ lua.globals().set("json", json_table).unwrap();
578578+579579+ let handle: mlua::Function = lua.globals().get("handle").unwrap();
580580+ let result: mlua::Value = handle.call_async(()).await.unwrap();
581581+ let json_result: Value = lua.from_value(result).unwrap();
582582+583583+ assert_eq!(json_result["status"], 200);
584584+ assert_eq!(json_result["inner_value"], 42);
585585+ }
586586+587587+ // -----------------------------------------------------------------------
588588+ // Lua integration: xrpc.procedure requires caller_did
589589+ // -----------------------------------------------------------------------
590590+591591+ #[tokio::test]
592592+ async fn lua_xrpc_procedure_fails_without_caller_did() {
593593+ let state = test_state();
594594+ let state_arc = Arc::new(state);
595595+ let lua = sandbox::create_sandbox().unwrap();
596596+597597+ // Register with no caller_did
598598+ register_xrpc_api(&lua, state_arc, None).unwrap();
599599+600600+ lua.load(
601601+ r#"
602602+ function handle()
603603+ return xrpc.procedure("test.something", {})
604604+ end
605605+ "#,
606606+ )
607607+ .exec()
608608+ .unwrap();
609609+610610+ let handle: mlua::Function = lua.globals().get("handle").unwrap();
611611+ let result: Result<mlua::Value, _> = handle.call_async(()).await;
612612+ assert!(result.is_err());
613613+ let err = result.unwrap_err().to_string();
614614+ assert!(
615615+ err.contains("requires authentication"),
616616+ "expected auth error, got: {err}"
617617+ );
618618+ }
619619+620620+ // -----------------------------------------------------------------------
621621+ // response_to_lua_table
622622+ // -----------------------------------------------------------------------
623623+624624+ #[tokio::test]
625625+ async fn response_to_lua_table_converts_correctly() {
626626+ let lua = sandbox::create_sandbox().unwrap();
627627+ let response = axum::response::Response::builder()
628628+ .status(200)
629629+ .body(axum::body::Body::from(r#"{"ok":true}"#))
630630+ .unwrap();
631631+632632+ let table = response_to_lua_table(&lua, response).await.unwrap();
633633+ assert_eq!(table.get::<u16>("status").unwrap(), 200);
634634+ assert_eq!(table.get::<String>("body").unwrap(), r#"{"ok":true}"#);
635635+ }
636636+637637+ #[tokio::test]
638638+ async fn response_to_lua_table_preserves_error_status() {
639639+ let lua = sandbox::create_sandbox().unwrap();
640640+ let response = axum::response::Response::builder()
641641+ .status(404)
642642+ .body(axum::body::Body::from("not found"))
643643+ .unwrap();
644644+645645+ let table = response_to_lua_table(&lua, response).await.unwrap();
646646+ assert_eq!(table.get::<u16>("status").unwrap(), 404);
647647+ assert_eq!(table.get::<String>("body").unwrap(), "not found");
648648+ }
649649+650650+ // -----------------------------------------------------------------------
651651+ // Claims::internal
652652+ // -----------------------------------------------------------------------
653653+654654+ #[test]
655655+ fn claims_internal_has_no_client_key() {
656656+ let claims = Claims::internal("did:plc:test".into());
657657+ assert_eq!(claims.did(), "did:plc:test");
658658+ assert!(claims.client_key().is_none());
659659+ }
660660+}
+5-5
src/xrpc/mod.rs
···11-mod procedure;
22-mod query;
11+pub(crate) mod procedure;
22+pub(crate) mod query;
3344use axum::Json;
55use axum::body::Body;
···19192020/// Parse a raw query string into a map where repeated keys become JSON arrays.
2121/// Single-value keys remain as JSON strings for backward compatibility.
2222-fn parse_query_params(query: &str) -> HashMap<String, Value> {
2222+pub(crate) fn parse_query_params(query: &str) -> HashMap<String, Value> {
2323 let mut multi: HashMap<String, Vec<String>> = HashMap::new();
2424 for pair in query.split('&') {
2525 if pair.is_empty() {
···5454/// HTTP query params arrive as strings. Without this, Lua scripts receive
5555/// `"25"` (a string) for `params.limit`, which Postgres rejects when used
5656/// in LIMIT (`argument of LIMIT must be type bigint, not type text`).
5757-fn coerce_params(params: &mut HashMap<String, Value>, parameters: &Value) {
5757+pub(crate) fn coerce_params(params: &mut HashMap<String, Value>, parameters: &Value) {
5858 let properties = match parameters.get("properties").and_then(|p| p.as_object()) {
5959 Some(p) => p,
6060 None => return,
···9898}
9999100100/// Proxy an unrecognized XRPC method to its home AppView resolved via DNS.
101101-async fn proxy_to_authority(
101101+pub(crate) async fn proxy_to_authority(
102102 state: &AppState,
103103 method: &str,
104104 query_string: &str,