diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index e66d5fd..23dad46 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -22,6 +22,7 @@ pub mod kv; pub mod pubsub; pub mod secrets; pub mod stdlib; +pub mod users; pub use bridge::{dynamic_to_json, json_to_dynamic}; pub use cx::SdkCallCx; @@ -45,5 +46,6 @@ pub fn register_all(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_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(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(async move { svc.get(&cx, id).await })?; + Ok(user_opt + .map(|u| Dynamic::from(user_to_map(&u))) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(async move { svc.find_by_email(&cx, &email).await })?; + Ok(user_opt + .map(|u| Dynamic::from(user_to_map(&u))) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(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(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() => { + let s = d.clone().into_string().unwrap_or_default(); + Some(parse_cursor(&s)?) + } + 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(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(async move { svc.login(&cx, &email, &password).await })?; + Ok(session_opt + .map(|s| Dynamic::from(s.token)) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(async move { svc.verify(&cx, &token).await })?; + Ok(user_opt + .map(|u| Dynamic::from(user_to_map(&u))) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(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(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(async move { svc.verify_email(&cx, &token).await })?; + Ok(user_opt + .map(|u| Dynamic::from(user_to_map(&u))) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(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(async move { + svc.complete_password_reset(&cx, &token, &new_password).await + })?; + Ok(user_opt + .map(|u| Dynamic::from(user_to_map(&u))) + .unwrap_or(Dynamic::UNIT)) + }, + ); +} + +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(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(async move { svc.accept_invite(&cx, &token, &password, None).await })?; + Ok(accept_opt + .map(|a| Dynamic::from(a.session.token)) + .unwrap_or(Dynamic::UNIT)) + }, + ); + } + { + 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(async move { + svc.accept_invite(&cx, &token, &password, display_name).await + })?; + Ok(accept_opt + .map(|a| Dynamic::from(a.session.token)) + .unwrap_or(Dynamic::UNIT)) + }, + ); + } +} + +// ---------------------------------------------------------------------------- +// 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(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(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(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(Dynamic::from) + .unwrap_or(Dynamic::UNIT), + ); + m.insert( + "email_verified_at".into(), + user.email_verified_at + .map(|d| Dynamic::from(d.to_rfc3339())) + .unwrap_or(Dynamic::UNIT), + ); + m.insert( + "last_login_at".into(), + user.last_login_at + .map(|d| Dynamic::from(d.to_rfc3339())) + .unwrap_or(Dynamic::UNIT), + ); + 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 + .map(|d| Dynamic::from(d.to_rfc3339())) + .unwrap_or(Dynamic::UNIT), + ); + m +} + +fn parse_cursor(s: &str) -> Result, Box> { + chrono::DateTime::parse_from_rfc3339(s) + .map(|d| d.with_timezone(&chrono::Utc)) + .map_err(|e| runtime_err(&format!("users::list: invalid cursor: {e}"))) +} + +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)?, + 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() +} + +/// Run a `UsersService` future inside the synchronous Rhai context. +/// Mirrors `kv::block_on` / `email::block_on`. +fn block_on(fut: F) -> Result> +where + F: std::future::Future> + Send, + T: Send, +{ + let handle = TokioHandle::try_current().map_err(|e| -> Box { + EvalAltResult::ErrorRuntime( + format!("users: no tokio runtime available: {e}").into(), + rhai::Position::NONE, + ) + .into() + })?; + handle.block_on(fut).map_err(|err| -> Box { + EvalAltResult::ErrorRuntime(format!("users: {err}").into(), rhai::Position::NONE).into() + }) +}