//! `secrets::` Rhai bridge — encrypted per-app secrets (v1.1.7). //! //! ```rhai //! secrets::set("stripe_key", "sk_live_xxx"); //! secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" }); //! let key = secrets::get("stripe_key"); // value or () //! let removed = secrets::delete("stripe_key"); // bool //! let page = secrets::list(#{ cursor: (), limit: 100 }); //! // page = #{ names: [...], next_cursor: () | "..." } //! ``` //! //! Collection-less (secrets are per-app, like pubsub topics) so there's //! no `::collection(...)`. Values are any JSON-serializable Rhai value //! (String/Map/Array/number/bool); a String round-trips back as a //! String. `app_id` is derived from `cx.app_id` in the service — it //! never appears in the script-side signature, preserving cross-app //! isolation. use std::sync::Arc; use picloud_shared::{SdkCallCx, SecretsListPage, Services}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use super::bridge::{ block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES, }; pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { let svc = services.secrets.clone(); let mut module = Module::new(); // secrets::set(name, value) — overwrites if present. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "set", move |name: &str, value: Dynamic| -> Result<(), Box> { let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?; let svc = svc.clone(); let cx = cx.clone(); block_on("secrets", async move { svc.set(&cx, name, json).await }) }, ); } // secrets::get(name) — decoded value, or () if missing. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "get", move |name: &str| -> Result> { let svc = svc.clone(); let cx = cx.clone(); let opt = block_on("secrets", async move { svc.get(&cx, name).await })?; Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic)) }, ); } // secrets::delete(name) — bool was-present. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "delete", move |name: &str| -> Result> { let svc = svc.clone(); let cx = cx.clone(); block_on("secrets", async move { svc.delete(&cx, name).await }) }, ); } // secrets::list(#{ cursor, limit }) — names only, cursor-paginated. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "list", move |opts: Map| -> Result> { let (cursor, limit) = parse_list_opts(&opts)?; let svc = svc.clone(); let cx = cx.clone(); let page: SecretsListPage = block_on("secrets", async move { svc.list(&cx, cursor.as_deref(), limit).await })?; Ok(list_page_to_map(page)) }, ); } engine.register_static_module("secrets", module.into()); } /// Pull `cursor` (string or `()`) and `limit` (int or `()`) out of the /// options map. Unknown/extra keys are ignored. fn parse_list_opts(opts: &Map) -> Result<(Option, u32), Box> { let cursor = match opts.get("cursor") { None => None, Some(d) if d.is_unit() => None, Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()), Some(_) => return Err(runtime_err("secrets::list: cursor must be a string or ()")), }; let limit = match opts.get("limit") { None => 0, Some(d) if d.is_unit() => 0, Some(d) => { let n = d .as_int() .map_err(|_| runtime_err("secrets::list: limit must be an integer or ()"))?; u32::try_from(n.max(0)).unwrap_or(u32::MAX) } }; Ok((cursor, limit)) } fn list_page_to_map(page: SecretsListPage) -> Map { let mut m = Map::new(); let names: Array = page.names.into_iter().map(Dynamic::from).collect(); m.insert("names".into(), names.into()); m.insert( "next_cursor".into(), page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from), ); m }