//! `users::` Rhai bridge — data-plane user management (v1.1.8). //! //! ```rhai //! // CRUD //! let u = users::create(#{ email: "a@b", password: "hunter22a", display_name: "Alice" }); //! let u = users::get(id); // map or () //! let u = users::find_by_email("a@b"); // map or () — REQUIRES an //! // authenticated principal //! // (anti-enumeration); forbidden //! // for anonymous public scripts. //! let free = users::email_available("a@b"); // bool — anonymous-safe pre-check //! // for self-serve registration. //! // Leaks only "exists/doesn't" but //! // is cheap + unthrottled: throttle //! // the route if enumeration matters. //! users::update(id, #{ display_name: "Alicia" }); //! let removed = users::delete(id); // bool //! let page = users::list(#{ "$limit": 50, cursor: () }); //! //! // Auth //! let tok = users::login("a@b", "hunter22a"); // session-token string or () //! let u = users::verify(tok); // map or () (sliding-TTL bump) //! users::logout(tok); //! //! // Email-tied (commit 5 / 6 / 7) //! users::send_verification_email(id, //! #{ link_base: "https://app/verify", subject: "Confirm", body_template: "..." }); //! let u = users::verify_email(token); //! users::request_password_reset("a@b", #{...}); //! let u = users::complete_password_reset(token, "new_pw"); //! users::invite("a@b", //! #{ link_base: "https://app/invite", subject: "Join", body_template: "...", //! display_name: "Bob", roles: ["editor"] }); //! let session_tok = users::accept_invite(token, "pw", "Bob"); //! //! // Roles (commit 9) //! users::add_role(id, "admin"); //! let removed = users::remove_role(id, "admin"); // bool //! let yes = users::has_role(id, "admin"); // bool //! ``` //! //! Collection-less surface (mirrors `email::` / `secrets::` rather //! than `kv::`/`docs::`/`files::`'s handle pattern). `app_id` is //! derived from `cx.app_id` in the service — it never appears on the //! script-side signature, preserving cross-app isolation. //! //! Methods bound but not yet implemented in the underlying service //! return a `users::not_implemented` runtime error. Subsequent v1.1.8 //! commits flesh them out without re-touching this file. use std::sync::Arc; use super::bridge::block_on; use picloud_shared::{ AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession, InviteOpts, SdkCallCx, Services, UpdateUserInput, UsersListOpts, UsersListPage, UsersService, }; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { let svc = services.users.clone(); let mut module = Module::new(); bind_create(&mut module, &svc, &cx); bind_get(&mut module, &svc, &cx); bind_find_by_email(&mut module, &svc, &cx); bind_email_available(&mut module, &svc, &cx); bind_update(&mut module, &svc, &cx); bind_delete(&mut module, &svc, &cx); bind_list(&mut module, &svc, &cx); bind_login(&mut module, &svc, &cx); bind_verify(&mut module, &svc, &cx); bind_logout(&mut module, &svc, &cx); bind_send_verification_email(&mut module, &svc, &cx); bind_verify_email(&mut module, &svc, &cx); bind_request_password_reset(&mut module, &svc, &cx); bind_complete_password_reset(&mut module, &svc, &cx); bind_invite(&mut module, &svc, &cx); bind_accept_invite(&mut module, &svc, &cx); bind_add_role(&mut module, &svc, &cx); bind_remove_role(&mut module, &svc, &cx); bind_has_role(&mut module, &svc, &cx); engine.register_static_module("users", module.into()); } // ---------------------------------------------------------------------------- // CRUD // ---------------------------------------------------------------------------- fn bind_create(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "create", move |opts: Map| -> Result> { let email = required_string(&opts, "email", "users::create")?; let password = required_string(&opts, "password", "users::create")?; let display_name = optional_string(&opts, "display_name"); let svc = svc.clone(); let cx = cx.clone(); let user = block_on("users", async move { svc.create( &cx, CreateUserInput { email, password, display_name, }, ) .await })?; Ok(user_to_map(&user)) }, ); } fn bind_get(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "get", move |id: &str| -> Result> { let id = parse_user_id(id, "users::get")?; let svc = svc.clone(); let cx = cx.clone(); let user_opt = block_on("users", async move { svc.get(&cx, id).await })?; Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u)))) }, ); } fn bind_find_by_email(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "find_by_email", move |email: &str| -> Result> { let email = email.to_string(); let svc = svc.clone(); let cx = cx.clone(); let user_opt = block_on("users", async move { svc.find_by_email(&cx, &email).await })?; Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u)))) }, ); } /// `users::email_available(email)` → `bool`. Anonymous-safe pre-check for /// self-serve registration (unlike `find_by_email`, which forbids /// anonymous callers). Lets a public register script branch on a free vs /// taken email without relying on `create`'s uniqueness error/502. fn bind_email_available(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "email_available", move |email: &str| -> Result> { let email = email.to_string(); let svc = svc.clone(); let cx = cx.clone(); block_on( "users", async move { svc.email_available(&cx, &email).await }, ) }, ); } fn bind_update(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "update", move |id: &str, patch: Map| -> Result> { let id = parse_user_id(id, "users::update")?; // display_name as Some(None) means "clear"; absent means // "leave alone". Differentiation: present-with-() vs absent. let display_name = if patch.contains_key("display_name") { Some(optional_string(&patch, "display_name")) } else { None }; let patch = UpdateUserInput { display_name }; let svc = svc.clone(); let cx = cx.clone(); let user = block_on("users", async move { svc.update(&cx, id, patch).await })?; Ok(user_to_map(&user)) }, ); } fn bind_delete(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "delete", move |id: &str| -> Result> { let id = parse_user_id(id, "users::delete")?; let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.delete(&cx, id).await }) }, ); } fn bind_list(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "list", move |opts: Map| -> Result> { let limit = match opts.get("$limit").or_else(|| opts.get("limit")) { None => None, Some(d) if d.is_unit() => None, Some(d) => Some( d.as_int() .map_err(|_| runtime_err("users::list: '$limit' must be an integer"))?, ), }; 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("users::list: cursor must be a string or ()")), }; let svc = svc.clone(); let cx = cx.clone(); let page: UsersListPage = block_on("users", async move { svc.list(&cx, UsersListOpts { cursor, limit }).await })?; Ok(list_page_to_map(&page)) }, ); } // ---------------------------------------------------------------------------- // Auth // ---------------------------------------------------------------------------- fn bind_login(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "login", move |email: &str, password: &str| -> Result> { let email = email.to_string(); let password = password.to_string(); let svc = svc.clone(); let cx = cx.clone(); let session_opt: Option = block_on( "users", async move { svc.login(&cx, &email, &password).await }, )?; Ok(session_opt.map_or(Dynamic::UNIT, |s| Dynamic::from(s.token))) }, ); } fn bind_verify(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "verify", move |token: &str| -> Result> { let token = token.to_string(); let svc = svc.clone(); let cx = cx.clone(); let user_opt = block_on("users", async move { svc.verify(&cx, &token).await })?; Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u)))) }, ); } fn bind_logout(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "logout", move |token: &str| -> Result<(), Box> { let token = token.to_string(); let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.logout(&cx, &token).await }) }, ); } // ---------------------------------------------------------------------------- // Email-tied (commit 5 / 6 / 7 fill the service impl behind these) // ---------------------------------------------------------------------------- fn bind_send_verification_email( module: &mut Module, svc: &Arc, cx: &Arc, ) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "send_verification_email", move |id: &str, opts: Map| -> Result<(), Box> { let id = parse_user_id(id, "users::send_verification_email")?; let opts = parse_email_template(&opts, "users::send_verification_email")?; let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.send_verification_email(&cx, id, opts).await }) }, ); } fn bind_verify_email(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "verify_email", move |token: &str| -> Result> { let token = token.to_string(); let svc = svc.clone(); let cx = cx.clone(); let user_opt = block_on("users", async move { svc.verify_email(&cx, &token).await })?; Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u)))) }, ); } fn bind_request_password_reset( module: &mut Module, svc: &Arc, cx: &Arc, ) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "request_password_reset", move |email: &str, opts: Map| -> Result<(), Box> { let email = email.to_string(); let opts = parse_email_template(&opts, "users::request_password_reset")?; let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.request_password_reset(&cx, &email, opts).await }) }, ); } fn bind_complete_password_reset( module: &mut Module, svc: &Arc, cx: &Arc, ) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "complete_password_reset", move |token: &str, new_password: &str| -> Result> { let token = token.to_string(); let new_password = new_password.to_string(); let svc = svc.clone(); let cx = cx.clone(); let user_opt = block_on("users", async move { svc.complete_password_reset(&cx, &token, &new_password) .await })?; Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u)))) }, ); } fn bind_invite(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "invite", move |email: &str, opts: Map| -> Result<(), Box> { let email = email.to_string(); let opts = parse_invite_opts(&opts)?; let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.invite(&cx, &email, opts).await }) }, ); } fn bind_accept_invite(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); // Two-arg overload: (token, password). The display_name overload is // bound separately because Rhai resolves functions by arity. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "accept_invite", move |token: &str, password: &str| -> Result> { let token = token.to_string(); let password = password.to_string(); let svc = svc.clone(); let cx = cx.clone(); let accept_opt: Option = block_on("users", async move { svc.accept_invite(&cx, &token, &password, None).await })?; Ok(accept_opt.map_or(Dynamic::UNIT, |a| Dynamic::from(a.session.token))) }, ); } { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "accept_invite", move |token: &str, password: &str, display_name: &str| -> Result> { let token = token.to_string(); let password = password.to_string(); let display_name = if display_name.trim().is_empty() { None } else { Some(display_name.trim().to_string()) }; let svc = svc.clone(); let cx = cx.clone(); let accept_opt: Option = block_on("users", async move { svc.accept_invite(&cx, &token, &password, display_name) .await })?; Ok(accept_opt.map_or(Dynamic::UNIT, |a| Dynamic::from(a.session.token))) }, ); } } // ---------------------------------------------------------------------------- // Roles (commit 9 fills the service impl) // ---------------------------------------------------------------------------- fn bind_add_role(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "add_role", move |id: &str, role: &str| -> Result<(), Box> { let id = parse_user_id(id, "users::add_role")?; let role = role.to_string(); let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.add_role(&cx, id, &role).await }) }, ); } fn bind_remove_role(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "remove_role", move |id: &str, role: &str| -> Result> { let id = parse_user_id(id, "users::remove_role")?; let role = role.to_string(); let svc = svc.clone(); let cx = cx.clone(); block_on( "users", async move { svc.remove_role(&cx, id, &role).await }, ) }, ); } fn bind_has_role(module: &mut Module, svc: &Arc, cx: &Arc) { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "has_role", move |id: &str, role: &str| -> Result> { let id = parse_user_id(id, "users::has_role")?; let role = role.to_string(); let svc = svc.clone(); let cx = cx.clone(); block_on("users", async move { svc.has_role(&cx, id, &role).await }) }, ); } // ---------------------------------------------------------------------------- // Shape conversion helpers // ---------------------------------------------------------------------------- fn user_to_map(user: &AppUser) -> Map { let mut m = Map::new(); m.insert("id".into(), Dynamic::from(user.id.to_string())); m.insert("email".into(), Dynamic::from(user.email.clone())); m.insert( "display_name".into(), user.display_name .clone() .map_or(Dynamic::UNIT, Dynamic::from), ); m.insert( "email_verified_at".into(), user.email_verified_at .map_or(Dynamic::UNIT, |d| Dynamic::from(d.to_rfc3339())), ); m.insert( "last_login_at".into(), user.last_login_at .map_or(Dynamic::UNIT, |d| Dynamic::from(d.to_rfc3339())), ); m.insert( "created_at".into(), Dynamic::from(user.created_at.to_rfc3339()), ); m.insert( "updated_at".into(), Dynamic::from(user.updated_at.to_rfc3339()), ); let roles: Array = user.roles.iter().cloned().map(Dynamic::from).collect(); m.insert("roles".into(), roles.into()); m } fn list_page_to_map(page: &UsersListPage) -> Map { let mut m = Map::new(); let items: Array = page .items .iter() .map(|u| Dynamic::from(user_to_map(u))) .collect(); m.insert("users".into(), items.into()); m.insert( "next_cursor".into(), page.next_cursor .as_ref() .map_or(Dynamic::UNIT, |s| Dynamic::from(s.clone())), ); m } fn parse_user_id(s: &str, ctx: &str) -> Result> { uuid::Uuid::parse_str(s) .map(AppUserId::from) .map_err(|e| runtime_err(&format!("{ctx}: id must be a UUID: {e}"))) } fn parse_email_template(opts: &Map, ctx: &str) -> Result> { Ok(EmailTemplateOpts { link_base: required_string(opts, "link_base", ctx)?, from: required_string(opts, "from", ctx)?, subject: required_string(opts, "subject", ctx)?, body_template: required_string(opts, "body_template", ctx)?, }) } fn parse_invite_opts(opts: &Map) -> Result> { // The template fields are optional only when invite is configured // to ship without an email; the service decides which is the // hard error. Here we forward whatever the script gave us. let template = if opts.contains_key("link_base") || opts.contains_key("subject") || opts.contains_key("body_template") { Some(parse_email_template(opts, "users::invite")?) } else { None }; let display_name = optional_string(opts, "display_name"); let roles = match opts.get("roles") { None => Vec::new(), Some(d) if d.is_unit() => Vec::new(), Some(d) => { if let Some(arr) = d.clone().try_cast::() { let mut out = Vec::with_capacity(arr.len()); for el in arr { if !el.is_string() { return Err(runtime_err( "users::invite: 'roles' must be an array of strings", )); } out.push(el.into_string().unwrap_or_default()); } out } else { return Err(runtime_err( "users::invite: 'roles' must be an array of strings", )); } } }; Ok(InviteOpts { template, display_name, roles, }) } fn required_string(opts: &Map, key: &str, ctx: &str) -> Result> { match opts.get(key) { Some(d) if d.is_string() => Ok(d.clone().into_string().unwrap_or_default()), _ => Err(runtime_err(&format!( "{ctx}: '{key}' must be a string and is required" ))), } } fn optional_string(opts: &Map, key: &str) -> Option { match opts.get(key) { None => None, Some(d) if d.is_unit() => None, Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()), Some(d) => Some(d.to_string()), } } #[allow(clippy::unnecessary_box_returns)] fn runtime_err(msg: &str) -> Box { EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into() }