From 71b5a84b2eb3ab76d431515fb3c8c721d3f01ed4 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 04 Mar 2026 21:28:46 +0000 Subject: [PATCH] feat: add Lua ENV vars --- migrations/20260304000003_create_script_variables.sql | 6 ++++++ src/admin/mod.rs | 6 ++++++ src/admin/script_variables.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/admin/types.rs | 18 ++++++++++++++++++ src/lua/context.rs | 32 ++++++++++++++++++++++++++++++++ src/lua/execute.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/app-sidebar.tsx | 2 ++ web/src/lib/api.ts | 28 ++++++++++++++++++++++++++++ web/src/types/script-variables.ts | 6 ++++++ web/src/app/(dashboard)/settings/page.tsx | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 10 file(s) changed, 510 insertion(s)(+), 0 deletion(s)(-) diff --git a/migrations/20260304000003_create_script_variables.sql b/migrations/20260304000003_create_script_variables.sql new file mode 100644 --- /dev/null +++ b/migrations/20260304000003_create_script_variables.sql @@ -0,0 +1,6 @@ +CREATE TABLE script_variables ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -5,6 +5,7 @@ mod lexicons; mod network_lexicons; mod records; +mod script_variables; mod stats; mod tap_stats; mod types; @@ -47,4 +48,9 @@ post(network_lexicons::add).get(network_lexicons::list), ) .route("/network-lexicons/{nsid}", delete(network_lexicons::remove)) + .route( + "/script-variables", + post(script_variables::upsert).get(script_variables::list), + ) + .route("/script-variables/{key}", delete(script_variables::delete)) } diff --git a/src/admin/script_variables.rs b/src/admin/script_variables.rs new file mode 100644 --- /dev/null +++ b/src/admin/script_variables.rs @@ -0,0 +1,119 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::auth::AdminAuth; +use super::types::{ScriptVariableSummary, UpsertScriptVariableBody}; + +/// GET /admin/script-variables — list all variables with masked preview. +pub(super) async fn list( + State(state): State, + _admin: AdminAuth, +) -> Result>, AppError> { + let rows: Vec<( + String, + String, + chrono::DateTime, + chrono::DateTime, + )> = sqlx::query_as( + "SELECT key, value, created_at, updated_at FROM script_variables ORDER BY key", + ) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list script variables: {e}")))?; + + let vars: Vec = rows + .into_iter() + .map(|(key, value, created_at, updated_at)| { + let preview = mask_value(&value); + ScriptVariableSummary { + key, + preview, + created_at, + updated_at, + } + }) + .collect(); + + Ok(Json(vars)) +} + +/// POST /admin/script-variables — create or update a variable. +pub(super) async fn upsert( + State(state): State, + auth: AdminAuth, + Json(body): Json, +) -> Result { + sqlx::query( + r#" + INSERT INTO script_variables (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW() + "#, + ) + .bind(&body.key) + .bind(&body.value) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to upsert script variable: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "script_variable.upserted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(body.key.clone()), + detail: serde_json::json!({}), + }, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/script-variables/{key} — delete a variable. +pub(super) async fn delete( + State(state): State, + auth: AdminAuth, + Path(key): Path, +) -> Result { + let result = sqlx::query("DELETE FROM script_variables WHERE key = $1") + .bind(&key) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete script variable: {e}")))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!( + "script variable '{key}' not found" + ))); + } + + log_event( + &state.db, + EventLog { + event_type: "script_variable.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(key), + detail: serde_json::json!({}), + }, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// Show first 4 characters then asterisks, or just asterisks for short values. +fn mask_value(value: &str) -> String { + if value.len() <= 4 { + "*".repeat(value.len()) + } else { + format!("{}****", &value[..4]) + } +} diff --git a/src/admin/types.rs b/src/admin/types.rs --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -116,3 +116,21 @@ pub(super) created_at: chrono::DateTime, pub(super) last_used_at: Option>, } + +// --------------------------------------------------------------------------- +// Script variable types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub(super) struct ScriptVariableSummary { + pub(super) key: String, + pub(super) preview: String, + pub(super) created_at: chrono::DateTime, + pub(super) updated_at: chrono::DateTime, +} + +#[derive(Deserialize)] +pub(super) struct UpsertScriptVariableBody { + pub(super) key: String, + pub(super) value: String, +} diff --git a/src/lua/context.rs b/src/lua/context.rs --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -32,6 +32,13 @@ Ok(()) } +/// Set the `env` global table from script variables. +pub fn set_env_context(lua: &Lua, vars: &HashMap) -> LuaResult<()> { + let globals = lua.globals(); + globals.set("env", lua.to_value(vars)?)?; + Ok(()) +} + /// Set global context variables for an index hook script. pub fn set_hook_context( lua: &Lua, @@ -139,6 +146,31 @@ let params_table: mlua::Table = globals.get("params").unwrap(); assert_eq!(params_table.get::("limit").unwrap(), "10"); assert_eq!(params_table.get::("cursor").unwrap(), "abc"); + } + + #[test] + fn env_context_sets_table() { + let lua = create_sandbox().unwrap(); + let mut vars = HashMap::new(); + vars.insert("API_KEY".to_string(), "secret123".to_string()); + vars.insert("OTHER".to_string(), "value".to_string()); + set_env_context(&lua, &vars).unwrap(); + + let globals = lua.globals(); + let env: mlua::Table = globals.get("env").unwrap(); + assert_eq!(env.get::("API_KEY").unwrap(), "secret123"); + assert_eq!(env.get::("OTHER").unwrap(), "value"); + } + + #[test] + fn env_context_empty_map() { + let lua = create_sandbox().unwrap(); + let vars = HashMap::new(); + set_env_context(&lua, &vars).unwrap(); + + let globals = lua.globals(); + let env: mlua::Table = globals.get("env").unwrap(); + assert!(env.get::("anything").unwrap().is_nil()); } #[test] diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -19,6 +19,16 @@ use super::record; use super::sandbox; +/// Load all script variables from the database as a key-value map. +async fn load_env_vars(db: &sqlx::PgPool) -> HashMap { + sqlx::query_as::<_, (String, String)>("SELECT key, value FROM script_variables") + .fetch_all(db) + .await + .unwrap_or_default() + .into_iter() + .collect() +} + /// Execute a Lua script for a procedure endpoint. pub async fn execute_procedure_script( state: &AppState, @@ -157,6 +167,28 @@ if let Err(e) = context::set_procedure_context(&lua, method, input, claims.did(), collection) { let error_message = format!("failed to set context: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + }), + }, + ) + .await; + return Err(AppError::Internal(error_message)); + } + + if let Err(e) = context::set_env_context(&lua, &load_env_vars(&state.db).await) { + let error_message = format!("failed to set env context: {e}"); log_event( &state.db, EventLog { @@ -383,6 +415,26 @@ if let Err(e) = context::set_query_context(&lua, method, params, collection) { let error_message = format!("failed to set context: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: None, + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "method": method, + }), + }, + ) + .await; + return Err(AppError::Internal(error_message)); + } + + if let Err(e) = context::set_env_context(&lua, &load_env_vars(&state.db).await) { + let error_message = format!("failed to set env context: {e}"); log_event( &state.db, EventLog { @@ -645,6 +697,9 @@ event.record, ) .map_err(|e| format!("failed to set hook context: {e}"))?; + + context::set_env_context(&lua, &load_env_vars(&event.state.db).await) + .map_err(|e| format!("failed to set env context: {e}"))?; lua.load(event.script) .exec() diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -7,6 +7,7 @@ IconTable, IconClipboardList, IconUsers, + IconSettings, IconLogout, } from "@tabler/icons-react" import Image from "next/image" @@ -33,6 +34,7 @@ { title: "Records", url: "/records", icon: IconTable }, { title: "Event Logs", url: "/events", icon: IconClipboardList }, { title: "Admins", url: "/admins", icon: IconUsers }, + { title: "Settings", url: "/settings", icon: IconSettings }, ] export function AppSidebar({ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -8,6 +8,7 @@ import type { AdminSummary } from "@/types/admins" import type { AdminListRecordsResponse } from "@/types/records" import type { EventsListResponse } from "@/types/events" +import type { ScriptVariableSummary } from "@/types/script-variables" export type { CollectionStat, StatsResponse } from "@/types/stats" export type { LexiconSummary, LexiconDetail } from "@/types/lexicons" @@ -17,6 +18,7 @@ export type { AdminSummary } from "@/types/admins" export type { AdminRecord, AdminListRecordsResponse } from "@/types/records" export type { EventLogEntry, EventsListResponse } from "@/types/events" +export type { ScriptVariableSummary } from "@/types/script-variables" // The DPoP proof for admin API calls must target AIP's userinfo URL, // because the backend forwards the proof to AIP for token validation. @@ -242,6 +244,32 @@ `/admin/records/collection?${new URLSearchParams({ collection })}`, getToken, { method: "DELETE" }, + ) +} + +// Script Variables +export function getScriptVariables(getToken: () => Promise) { + return apiFetch("/admin/script-variables", getToken) +} + +export function upsertScriptVariable( + getToken: () => Promise, + body: { key: string; value: string } +) { + return apiFetch("/admin/script-variables", getToken, { + method: "POST", + body: JSON.stringify(body), + }) +} + +export function deleteScriptVariable( + getToken: () => Promise, + key: string +) { + return apiFetch( + `/admin/script-variables/${encodeURIComponent(key)}`, + getToken, + { method: "DELETE" } ) } diff --git a/web/src/types/script-variables.ts b/web/src/types/script-variables.ts new file mode 100644 --- /dev/null +++ b/web/src/types/script-variables.ts @@ -0,0 +1,6 @@ +export interface ScriptVariableSummary { + key: string + preview: string + created_at: string + updated_at: string +} diff --git a/web/src/app/(dashboard)/settings/page.tsx b/web/src/app/(dashboard)/settings/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { useAuth } from "@/lib/auth-context"; +import { + getScriptVariables, + upsertScriptVariable, + deleteScriptVariable, +} from "@/lib/api"; +import type { ScriptVariableSummary } from "@/types/script-variables"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Trash2, Pencil } from "lucide-react"; +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 { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function SettingsPage() { + const { getToken } = useAuth(); + const [vars, setVars] = useState([]); + const [error, setError] = useState(null); + + const load = useCallback(() => { + getScriptVariables(getToken) + .then(setVars) + .catch((e) => setError(e.message)); + }, [getToken]); + + useEffect(() => { + load(); + }, [load]); + + async function handleDelete(key: string) { + try { + await deleteScriptVariable(getToken, key); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + <> + +
+ {error &&

{error}

} + +
+
+

Script Variables

+

+ Define variables that Lua scripts can access via the{" "} + env global table. +

+
+ +
+ +
+ + + + Key + Preview + Updated + + + + + {vars.length === 0 && ( + + + No script variables yet. + + + )} + {vars.map((v) => ( + + {v.key} + + {v.preview} + + + {new Date(v.updated_at).toLocaleString()} + + +
+ + +
+
+
+ ))} +
+
+
+
+ + ); +} + +function UpsertVariableDialog({ + getToken, + onSuccess, + editKey, +}: { + getToken: () => Promise; + onSuccess: () => void; + editKey?: string; +}) { + const [key, setKey] = useState(editKey ?? ""); + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + + const isEdit = !!editKey; + + async function handleSave() { + setError(null); + try { + await upsertScriptVariable(getToken, { + key: isEdit ? editKey : key, + value, + }); + setKey(editKey ?? ""); + setValue(""); + setOpen(false); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + { + setOpen(o); + if (o) { + setKey(editKey ?? ""); + setValue(""); + setError(null); + } + }} + > + + {isEdit ? ( + + ) : ( + + )} + + + + {isEdit ? "Edit Variable" : "Add Variable"} + + {isEdit + ? "Update the value for this script variable." + : "Add a new script variable accessible via env.KEY in Lua scripts."} + + +
+ {error &&

{error}

} +
+ + setKey(e.target.value)} + placeholder="VARIABLE_NAME" + disabled={isEdit} + className={isEdit ? "font-mono" : ""} + /> +
+
+ +