diff --git a/Cargo.lock b/Cargo.lock index 2650c9b..fbe7da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "switchboard-guest-sdk", ] +[[package]] +name = "clerk-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "switchboard-guest-sdk", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/README.md b/README.md index c9a7d63..d302533 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A home for integrations that make more sense as standalone WASM modules than as | Plugin | Tools | Description | |--------|-------|-------------| | [bland](plugins/bland/) | 30 | Bland.ai voice AI: calls, transcripts, voices, pathways, inbound numbers, knowledge bases, org management, billing, audit logs | +| [clerk](plugins/clerk/) | 34 | Clerk authentication and identity: users, sessions, organizations, memberships, invitations, allow/block list identifiers | | [looker](plugins/looker/) | 23 | Looker BI: dashboards, Looks, LookML models, inline analytics queries, SQL Runner | Prebuilt binaries live in [`dist/`](dist/) and are referenced by [`manifest.json`](manifest.json). diff --git a/dist/clerk.wasm b/dist/clerk.wasm new file mode 100755 index 0000000..1ff9adc Binary files /dev/null and b/dist/clerk.wasm differ diff --git a/manifest.json b/manifest.json index 6bca8bd..0a44c10 100644 --- a/manifest.json +++ b/manifest.json @@ -22,6 +22,25 @@ } ] }, + { + "name": "clerk", + "description": "Clerk authentication and identity management — users, sessions, organizations, memberships, invitations, and allow/block list identifiers via the Clerk Backend API.", + "author": "daltoniam", + "homepage": "https://github.com/daltoniam/switchboard_plugins", + "license": "MIT", + "versions": [ + { + "version": "0.1.0", + "abi_min": 1, + "abi_max": 1, + "url": "https://raw.githubusercontent.com/daltoniam/switchboard_plugins/main/dist/clerk.wasm", + "sha256": "6e588545f08256ccddfe45df86d22ff656ab549f2d24e201f73c3d11d94540d8", + "size": 211073, + "released_at": "2026-05-21T23:42:41Z", + "changelog": "Initial release. 34 tools across users, sessions, organizations, memberships, invitations, and allow/block list identifiers." + } + ] + }, { "name": "looker", "description": "Looker BI integration — dashboards, Looks, LookML models, inline analytics queries, SQL Runner.", diff --git a/plugins/clerk/Cargo.toml b/plugins/clerk/Cargo.toml new file mode 100644 index 0000000..45925f3 --- /dev/null +++ b/plugins/clerk/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "clerk-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +switchboard-guest-sdk = { git = "https://github.com/daltoniam/switchboard.git" } +serde.workspace = true +serde_json.workspace = true diff --git a/plugins/clerk/README.md b/plugins/clerk/README.md new file mode 100644 index 0000000..d388a10 --- /dev/null +++ b/plugins/clerk/README.md @@ -0,0 +1,70 @@ +# Clerk Switchboard plugin + +Clerk authentication and identity management integration — users, sessions, organizations, memberships, invitations, and allow/block list identifiers via the Clerk Backend API. + +## Credentials + +| Key | Required | Description | +|-----|----------|-------------| +| `secret_key` | yes | Clerk Backend API secret key (`sk_test_...` or `sk_live_...`) from the Clerk Dashboard under API Keys → Secret keys. Sent as `Authorization: Bearer `. | + +## Tools + +### Users + +| Tool | Description | +|------|-------------| +| `clerk_list_users` | Search and list Clerk users by email, phone, username, or user ID; filter by organization, banned/locked state, last activity | +| `clerk_get_user` | Get a user's full profile, email addresses, phone numbers, external accounts, metadata, and timestamps | +| `clerk_create_user` | Create a new Clerk user with email, phone, username, password, name, and metadata | +| `clerk_update_user` | Update a user's profile, metadata, password, or primary identifier | +| `clerk_delete_user` | Permanently delete a Clerk user | +| `clerk_ban_user` | Ban a user, preventing all sign-ins | +| `clerk_unban_user` | Lift a ban on a user | +| `clerk_lock_user` | Lock a user out of new sign-ins | +| `clerk_unlock_user` | Unlock a previously locked user | +| `clerk_list_user_organization_memberships` | List all organizations a user belongs to with their role | + +### Sessions + +| Tool | Description | +|------|-------------| +| `clerk_list_sessions` | List Clerk sessions filtered by user, client, or status | +| `clerk_get_session` | Get session details including user, status, expiry, and last activity | +| `clerk_revoke_session` | Revoke a session, signing the user out of that client | + +### Organizations + +| Tool | Description | +|------|-------------| +| `clerk_list_organizations` | List or search Clerk organizations (tenants, workspaces, teams) | +| `clerk_get_organization` | Get organization details by ID or slug | +| `clerk_create_organization` | Create a new organization with a name, slug, and creator user | +| `clerk_update_organization` | Update an organization's name, slug, max memberships, or metadata | +| `clerk_delete_organization` | Permanently delete an organization | +| `clerk_list_organization_memberships` | List members of an organization with their role | +| `clerk_create_organization_membership` | Add a user to an organization with a role | +| `clerk_update_organization_membership` | Change a member's role within an organization | +| `clerk_delete_organization_membership` | Remove a user from an organization | +| `clerk_list_organization_invitations` | List pending/accepted/revoked invitations for an organization | +| `clerk_create_organization_invitation` | Invite a user (by email) to an organization with a role | +| `clerk_revoke_organization_invitation` | Revoke a pending organization invitation | + +### Invitations (instance-level) + +| Tool | Description | +|------|-------------| +| `clerk_list_invitations` | List instance-level invitations sent to email addresses | +| `clerk_create_invitation` | Send an instance-level invitation to an email address | +| `clerk_revoke_invitation` | Revoke a pending instance-level invitation | + +### Allow / Block list + +| Tool | Description | +|------|-------------| +| `clerk_list_allowlist_identifiers` | List identifiers (emails, phones, domains) allowed to sign up | +| `clerk_create_allowlist_identifier` | Add an identifier to the sign-up allow list | +| `clerk_delete_allowlist_identifier` | Remove an identifier from the sign-up allow list | +| `clerk_list_blocklist_identifiers` | List identifiers blocked from signing up | +| `clerk_create_blocklist_identifier` | Add an identifier to the sign-up block list | +| `clerk_delete_blocklist_identifier` | Remove an identifier from the sign-up block list | diff --git a/plugins/clerk/src/handlers.rs b/plugins/clerk/src/handlers.rs new file mode 100644 index 0000000..b79aa98 --- /dev/null +++ b/plugins/clerk/src/handlers.rs @@ -0,0 +1,837 @@ +use std::collections::HashMap; +use switchboard_guest_sdk as sdk; + +use crate::{clerk_delete, clerk_get, clerk_patch, clerk_post, path_escape, query_escape}; + +macro_rules! call { + ($call:expr) => { + match $call { + Ok(v) => v, + Err(e) => return sdk::err_result(&e), + } + }; +} + +fn json_result(v: &serde_json::Value) -> sdk::ToolResult { + match serde_json::to_string(v) { + Ok(s) => sdk::raw_result(s), + Err(e) => sdk::err_result(&format!("encode response: {e}")), + } +} + +fn required( + args: &HashMap, + key: &str, +) -> Result { + let value = sdk::arg_str(args, key); + if value.is_empty() { + Err(sdk::err_result(&format!("{key} is required"))) + } else { + Ok(value) + } +} + +fn add_str_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + let value = sdk::arg_str(args, arg); + if !value.is_empty() { + params.push((key.to_string(), value)); + } +} + +fn add_int_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + if let Some(value) = sdk::arg_int(args, arg) { + params.push((key.to_string(), value.to_string())); + } +} + +fn add_bool_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + if let Some(value) = sdk::arg_bool(args, arg) { + params.push((key.to_string(), value.to_string())); + } +} + +/// Repeats an array-valued query parameter as `key=v1&key=v2` after splitting on commas. +fn add_repeated_param( + args: &HashMap, + params: &mut Vec<(String, String)>, + arg: &str, + key: &str, +) { + let value = sdk::arg_str(args, arg); + if value.is_empty() { + return; + } + for piece in value.split(',') { + let trimmed = piece.trim(); + if !trimmed.is_empty() { + params.push((key.to_string(), trimmed.to_string())); + } + } +} + +fn append_query(path: &str, params: &[(String, String)]) -> String { + if params.is_empty() { + return path.to_string(); + } + let query = params + .iter() + .map(|(key, value)| format!("{}={}", query_escape(key), query_escape(value))) + .collect::>() + .join("&"); + format!("{path}?{query}") +} + +fn insert_string_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + let value = sdk::arg_str(args, key); + if !value.is_empty() { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_i64_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = sdk::arg_int(args, key) { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_bool_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = sdk::arg_bool(args, key) { + body.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_json_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) -> Result<(), String> { + match args.get(key) { + Some(serde_json::Value::String(s)) if !s.is_empty() => { + let parsed: serde_json::Value = + serde_json::from_str(s).map_err(|e| format!("{key} must be valid JSON: {e}"))?; + body.insert(key.to_string(), parsed); + } + Some(v) if !v.is_null() && !matches!(v, serde_json::Value::String(s) if s.is_empty()) => { + body.insert(key.to_string(), v.clone()); + } + _ => {} + } + Ok(()) +} + +/// Coerces a value that may arrive as a JSON array or a comma-separated string into a JSON array. +fn insert_string_array_body( + args: &HashMap, + body: &mut serde_json::Map, + key: &str, +) -> Result<(), String> { + match args.get(key) { + Some(serde_json::Value::Array(arr)) => { + body.insert(key.to_string(), serde_json::Value::Array(arr.clone())); + } + Some(serde_json::Value::String(s)) if !s.is_empty() => { + let trimmed = s.trim(); + if trimmed.starts_with('[') { + let parsed: serde_json::Value = serde_json::from_str(trimmed) + .map_err(|e| format!("{key} must be a JSON array: {e}"))?; + if !parsed.is_array() { + return Err(format!("{key} must be a JSON array")); + } + body.insert(key.to_string(), parsed); + } else { + let values: Vec = trimmed + .split(',') + .filter_map(|p| { + let t = p.trim(); + if t.is_empty() { + None + } else { + Some(serde_json::Value::String(t.to_string())) + } + }) + .collect(); + body.insert(key.to_string(), serde_json::Value::Array(values)); + } + } + _ => {} + } + Ok(()) +} + +// ── Users ─────────────────────────────────────────────────────────────────── + +pub fn list_users(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 500) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + add_str_param(&args, &mut params, "order_by", "order_by"); + add_str_param(&args, &mut params, "query", "query"); + add_repeated_param(&args, &mut params, "email_address", "email_address"); + add_repeated_param(&args, &mut params, "phone_number", "phone_number"); + add_repeated_param(&args, &mut params, "username", "username"); + add_repeated_param(&args, &mut params, "user_id", "user_id"); + add_repeated_param(&args, &mut params, "external_id", "external_id"); + add_repeated_param(&args, &mut params, "organization_id", "organization_id"); + add_bool_param(&args, &mut params, "banned", "banned"); + add_bool_param(&args, &mut params, "locked", "locked"); + add_int_param( + &args, + &mut params, + "last_active_at_since", + "last_active_at_since", + ); + + let v = call!(clerk_get(&append_query("/users", ¶ms))); + json_result(&v) +} + +pub fn get_user(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_get(&format!("/users/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn create_user(args: HashMap) -> sdk::ToolResult { + let mut body = serde_json::Map::new(); + + for key in ["email_address", "phone_number", "web3_wallet"] { + if let Err(e) = insert_string_array_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + for key in [ + "username", + "password", + "password_digest", + "password_hasher", + "first_name", + "last_name", + "external_id", + "created_at", + ] { + insert_string_body(&args, &mut body, key); + } + for key in ["skip_password_checks", "skip_password_requirement"] { + insert_bool_body(&args, &mut body, key); + } + for key in ["public_metadata", "private_metadata", "unsafe_metadata"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(clerk_post("/users", &serde_json::Value::Object(body))); + json_result(&v) +} + +pub fn update_user(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + for key in [ + "first_name", + "last_name", + "username", + "primary_email_address_id", + "primary_phone_number_id", + "primary_web3_wallet_id", + "profile_image_id", + "password", + "password_digest", + "password_hasher", + "external_id", + ] { + insert_string_body(&args, &mut body, key); + } + insert_bool_body(&args, &mut body, "sign_out_of_other_sessions"); + for key in ["public_metadata", "private_metadata", "unsafe_metadata"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(clerk_patch( + &format!("/users/{}", path_escape(&id)), + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn delete_user(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_delete(&format!("/users/{}", path_escape(&id)))); + json_result(&v) +} + +fn user_action(args: HashMap, action: &str) -> sdk::ToolResult { + let id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_post( + &format!("/users/{}/{}", path_escape(&id), action), + &serde_json::json!({}) + )); + json_result(&v) +} + +pub fn ban_user(args: HashMap) -> sdk::ToolResult { + user_action(args, "ban") +} + +pub fn unban_user(args: HashMap) -> sdk::ToolResult { + user_action(args, "unban") +} + +pub fn lock_user(args: HashMap) -> sdk::ToolResult { + user_action(args, "lock") +} + +pub fn unlock_user(args: HashMap) -> sdk::ToolResult { + user_action(args, "unlock") +} + +pub fn list_user_organization_memberships( + args: HashMap, +) -> sdk::ToolResult { + let id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + + let params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 100) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + + let v = call!(clerk_get(&append_query( + &format!("/users/{}/organization_memberships", path_escape(&id)), + ¶ms + ))); + json_result(&v) +} + +// ── Sessions ──────────────────────────────────────────────────────────────── + +pub fn list_sessions(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = Vec::new(); + add_str_param(&args, &mut params, "client_id", "client_id"); + add_str_param(&args, &mut params, "user_id", "user_id"); + add_str_param(&args, &mut params, "status", "status"); + + let has_filter = params + .iter() + .any(|(k, _)| k == "client_id" || k == "user_id"); + if !has_filter { + return sdk::err_result("user_id or client_id is required"); + } + + let v = call!(clerk_get(&append_query("/sessions", ¶ms))); + json_result(&v) +} + +pub fn get_session(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "session_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_get(&format!("/sessions/{}", path_escape(&id)))); + json_result(&v) +} + +pub fn revoke_session(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "session_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_post( + &format!("/sessions/{}/revoke", path_escape(&id)), + &serde_json::json!({}) + )); + json_result(&v) +} + +// ── Organizations ─────────────────────────────────────────────────────────── + +pub fn list_organizations(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 500) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + add_bool_param( + &args, + &mut params, + "include_members_count", + "include_members_count", + ); + add_str_param(&args, &mut params, "order_by", "order_by"); + add_str_param(&args, &mut params, "query", "query"); + add_repeated_param(&args, &mut params, "user_id", "user_id"); + + let v = call!(clerk_get(&append_query("/organizations", ¶ms))); + json_result(&v) +} + +pub fn get_organization(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let mut params: Vec<(String, String)> = Vec::new(); + add_bool_param( + &args, + &mut params, + "include_members_count", + "include_members_count", + ); + let v = call!(clerk_get(&append_query( + &format!("/organizations/{}", path_escape(&id)), + ¶ms + ))); + json_result(&v) +} + +pub fn create_organization(args: HashMap) -> sdk::ToolResult { + let name = match required(&args, "name") { + Ok(v) => v, + Err(e) => return e, + }; + let created_by = match required(&args, "created_by") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + body.insert("name".into(), serde_json::json!(name)); + body.insert("created_by".into(), serde_json::json!(created_by)); + insert_string_body(&args, &mut body, "slug"); + insert_i64_body(&args, &mut body, "max_allowed_memberships"); + for key in ["public_metadata", "private_metadata"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(clerk_post( + "/organizations", + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn update_organization(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + insert_string_body(&args, &mut body, "name"); + insert_string_body(&args, &mut body, "slug"); + insert_i64_body(&args, &mut body, "max_allowed_memberships"); + insert_bool_body(&args, &mut body, "admin_delete_enabled"); + for key in ["public_metadata", "private_metadata"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(clerk_patch( + &format!("/organizations/{}", path_escape(&id)), + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn delete_organization(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_delete(&format!( + "/organizations/{}", + path_escape(&id) + ))); + json_result(&v) +} + +pub fn list_organization_memberships(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let mut params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 500) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + add_str_param(&args, &mut params, "order_by", "order_by"); + + let v = call!(clerk_get(&append_query( + &format!("/organizations/{}/memberships", path_escape(&id)), + ¶ms + ))); + json_result(&v) +} + +pub fn create_organization_membership(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let user_id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let role = match required(&args, "role") { + Ok(v) => v, + Err(e) => return e, + }; + + let body = serde_json::json!({ + "user_id": user_id, + "role": role, + }); + let v = call!(clerk_post( + &format!("/organizations/{}/memberships", path_escape(&org_id)), + &body + )); + json_result(&v) +} + +pub fn update_organization_membership(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let user_id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let role = match required(&args, "role") { + Ok(v) => v, + Err(e) => return e, + }; + + let body = serde_json::json!({ "role": role }); + let v = call!(clerk_patch( + &format!( + "/organizations/{}/memberships/{}", + path_escape(&org_id), + path_escape(&user_id) + ), + &body + )); + json_result(&v) +} + +pub fn delete_organization_membership(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let user_id = match required(&args, "user_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_delete(&format!( + "/organizations/{}/memberships/{}", + path_escape(&org_id), + path_escape(&user_id) + ))); + json_result(&v) +} + +pub fn list_organization_invitations(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 500) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + add_repeated_param(&args, &mut params, "status", "status"); + + let v = call!(clerk_get(&append_query( + &format!("/organizations/{}/invitations", path_escape(&id)), + ¶ms + ))); + json_result(&v) +} + +pub fn create_organization_invitation(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let email_address = match required(&args, "email_address") { + Ok(v) => v, + Err(e) => return e, + }; + let role = match required(&args, "role") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + body.insert("email_address".into(), serde_json::json!(email_address)); + body.insert("role".into(), serde_json::json!(role)); + insert_string_body(&args, &mut body, "inviter_user_id"); + insert_string_body(&args, &mut body, "redirect_url"); + for key in ["public_metadata", "private_metadata"] { + if let Err(e) = insert_json_body(&args, &mut body, key) { + return sdk::err_result(&e); + } + } + + let v = call!(clerk_post( + &format!("/organizations/{}/invitations", path_escape(&org_id)), + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn revoke_organization_invitation(args: HashMap) -> sdk::ToolResult { + let org_id = match required(&args, "organization_id") { + Ok(v) => v, + Err(e) => return e, + }; + let invitation_id = match required(&args, "invitation_id") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + insert_string_body(&args, &mut body, "requesting_user_id"); + + let v = call!(clerk_post( + &format!( + "/organizations/{}/invitations/{}/revoke", + path_escape(&org_id), + path_escape(&invitation_id) + ), + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +// ── Invitations ───────────────────────────────────────────────────────────── + +pub fn list_invitations(args: HashMap) -> sdk::ToolResult { + let mut params: Vec<(String, String)> = vec![ + ( + "limit".into(), + sdk::arg_int(&args, "limit") + .unwrap_or(10) + .clamp(1, 500) + .to_string(), + ), + ( + "offset".into(), + sdk::arg_int(&args, "offset") + .unwrap_or(0) + .max(0) + .to_string(), + ), + ]; + add_str_param(&args, &mut params, "status", "status"); + add_str_param(&args, &mut params, "query", "query"); + add_str_param(&args, &mut params, "order_by", "order_by"); + + let v = call!(clerk_get(&append_query("/invitations", ¶ms))); + json_result(&v) +} + +pub fn create_invitation(args: HashMap) -> sdk::ToolResult { + let email_address = match required(&args, "email_address") { + Ok(v) => v, + Err(e) => return e, + }; + + let mut body = serde_json::Map::new(); + body.insert("email_address".into(), serde_json::json!(email_address)); + insert_string_body(&args, &mut body, "redirect_url"); + insert_string_body(&args, &mut body, "template_slug"); + insert_bool_body(&args, &mut body, "notify"); + insert_bool_body(&args, &mut body, "ignore_existing"); + insert_i64_body(&args, &mut body, "expires_in_days"); + if let Err(e) = insert_json_body(&args, &mut body, "public_metadata") { + return sdk::err_result(&e); + } + + let v = call!(clerk_post("/invitations", &serde_json::Value::Object(body))); + json_result(&v) +} + +pub fn revoke_invitation(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "invitation_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_post( + &format!("/invitations/{}/revoke", path_escape(&id)), + &serde_json::json!({}) + )); + json_result(&v) +} + +// ── Allow / Block list ────────────────────────────────────────────────────── + +pub fn list_allowlist_identifiers(_args: HashMap) -> sdk::ToolResult { + let v = call!(clerk_get("/allowlist_identifiers")); + json_result(&v) +} + +pub fn create_allowlist_identifier(args: HashMap) -> sdk::ToolResult { + let identifier = match required(&args, "identifier") { + Ok(v) => v, + Err(e) => return e, + }; + let mut body = serde_json::Map::new(); + body.insert("identifier".into(), serde_json::json!(identifier)); + insert_bool_body(&args, &mut body, "notify"); + + let v = call!(clerk_post( + "/allowlist_identifiers", + &serde_json::Value::Object(body) + )); + json_result(&v) +} + +pub fn delete_allowlist_identifier(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "identifier_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_delete(&format!( + "/allowlist_identifiers/{}", + path_escape(&id) + ))); + json_result(&v) +} + +pub fn list_blocklist_identifiers(_args: HashMap) -> sdk::ToolResult { + let v = call!(clerk_get("/blocklist_identifiers")); + json_result(&v) +} + +pub fn create_blocklist_identifier(args: HashMap) -> sdk::ToolResult { + let identifier = match required(&args, "identifier") { + Ok(v) => v, + Err(e) => return e, + }; + let body = serde_json::json!({ "identifier": identifier }); + + let v = call!(clerk_post("/blocklist_identifiers", &body)); + json_result(&v) +} + +pub fn delete_blocklist_identifier(args: HashMap) -> sdk::ToolResult { + let id = match required(&args, "identifier_id") { + Ok(v) => v, + Err(e) => return e, + }; + let v = call!(clerk_delete(&format!( + "/blocklist_identifiers/{}", + path_escape(&id) + ))); + json_result(&v) +} diff --git a/plugins/clerk/src/lib.rs b/plugins/clerk/src/lib.rs new file mode 100644 index 0000000..0276af2 --- /dev/null +++ b/plugins/clerk/src/lib.rs @@ -0,0 +1,481 @@ +mod handlers; +mod tools; + +use std::collections::HashMap; +use std::sync::Mutex; +use switchboard_guest_sdk as sdk; + +#[cfg(test)] +#[no_mangle] +pub extern "C" fn host_http_request(_ptr_size: u64) -> u64 { + 0 +} + +#[cfg(test)] +#[no_mangle] +pub extern "C" fn host_log(_ptr: u32, _size: u32) {} + +const API_BASE: &str = "https://api.clerk.com/v1"; + +struct Config { + secret_key: String, +} + +static CONFIG: Mutex> = Mutex::new(None); + +fn with_config(f: F) -> Result +where + F: FnOnce(&Config) -> R, +{ + let guard = CONFIG.lock().map_err(|e| e.to_string())?; + match guard.as_ref() { + Some(c) => Ok(f(c)), + None => Err("clerk: not configured".into()), + } +} + +#[no_mangle] +pub extern "C" fn name() -> u64 { + sdk::leaked_string("clerk") +} + +#[no_mangle] +pub extern "C" fn metadata() -> u64 { + sdk::leaked_metadata(&sdk::PluginMetadata { + name: "clerk".into(), + version: "0.1.0".into(), + abi_version: 1, + description: "Clerk authentication and identity management — users, sessions, organizations, memberships, invitations, and allow/block list identifiers via the Clerk Backend API.".into(), + author: "daltoniam".into(), + homepage: "https://github.com/daltoniam/switchboard_plugins".into(), + license: "MIT".into(), + capabilities: vec!["http".into()], + credential_keys: vec!["secret_key".into()], + plain_text_keys: vec![], + optional_keys: vec![], + placeholders: HashMap::from([( + "secret_key".into(), + "Clerk secret key (sk_test_... or sk_live_...)".into(), + )]), + }) +} + +#[no_mangle] +pub extern "C" fn tools() -> u64 { + let defs = tools::tool_definitions(); + let data = serde_json::to_vec(&defs).unwrap_or_default(); + sdk::leaked_result(&data) +} + +#[no_mangle] +pub extern "C" fn configure(ptr_size: u64) -> u64 { + let input = sdk::read_input(ptr_size); + let creds: HashMap = match serde_json::from_slice(&input) { + Ok(c) => c, + Err(e) => return sdk::leaked_string(&format!("clerk: invalid credentials JSON: {e}")), + }; + + let secret_key = creds.get("secret_key").cloned().unwrap_or_default(); + if secret_key.is_empty() { + return sdk::leaked_string("clerk: secret_key is required"); + } + + *CONFIG.lock().unwrap() = Some(Config { secret_key }); + 0 +} + +#[no_mangle] +pub extern "C" fn execute(ptr_size: u64) -> u64 { + let input = sdk::read_input(ptr_size); + let req: sdk::ExecuteRequest = match serde_json::from_slice(&input) { + Ok(r) => r, + Err(e) => { + let r = sdk::err_result(&format!("invalid request: {e}")); + let data = serde_json::to_vec(&r).unwrap_or_default(); + return sdk::leaked_result(&data); + } + }; + + let result = dispatch(&req.tool_name, req.args); + let data = serde_json::to_vec(&result).unwrap_or_default(); + sdk::leaked_result(&data) +} + +#[no_mangle] +pub extern "C" fn healthy() -> i32 { + // JWKS is the cheapest authenticated read; succeeds iff the secret key is valid. + match clerk_get("/jwks") { + Ok(_) => 1, + Err(_) => 0, + } +} + +#[no_mangle] +pub extern "C" fn compact_specs() -> u64 { + sdk::leaked_compact_specs(&compact_spec_map()) +} + +type HandlerFn = fn(HashMap) -> sdk::ToolResult; + +fn dispatch(tool_name: &str, args: HashMap) -> sdk::ToolResult { + let handler: Option = match tool_name { + // Users + "clerk_list_users" => Some(handlers::list_users), + "clerk_get_user" => Some(handlers::get_user), + "clerk_create_user" => Some(handlers::create_user), + "clerk_update_user" => Some(handlers::update_user), + "clerk_delete_user" => Some(handlers::delete_user), + "clerk_ban_user" => Some(handlers::ban_user), + "clerk_unban_user" => Some(handlers::unban_user), + "clerk_lock_user" => Some(handlers::lock_user), + "clerk_unlock_user" => Some(handlers::unlock_user), + "clerk_list_user_organization_memberships" => { + Some(handlers::list_user_organization_memberships) + } + // Sessions + "clerk_list_sessions" => Some(handlers::list_sessions), + "clerk_get_session" => Some(handlers::get_session), + "clerk_revoke_session" => Some(handlers::revoke_session), + // Organizations + "clerk_list_organizations" => Some(handlers::list_organizations), + "clerk_get_organization" => Some(handlers::get_organization), + "clerk_create_organization" => Some(handlers::create_organization), + "clerk_update_organization" => Some(handlers::update_organization), + "clerk_delete_organization" => Some(handlers::delete_organization), + "clerk_list_organization_memberships" => Some(handlers::list_organization_memberships), + "clerk_create_organization_membership" => Some(handlers::create_organization_membership), + "clerk_update_organization_membership" => Some(handlers::update_organization_membership), + "clerk_delete_organization_membership" => Some(handlers::delete_organization_membership), + "clerk_list_organization_invitations" => Some(handlers::list_organization_invitations), + "clerk_create_organization_invitation" => Some(handlers::create_organization_invitation), + "clerk_revoke_organization_invitation" => Some(handlers::revoke_organization_invitation), + // Invitations + "clerk_list_invitations" => Some(handlers::list_invitations), + "clerk_create_invitation" => Some(handlers::create_invitation), + "clerk_revoke_invitation" => Some(handlers::revoke_invitation), + // Allow/Block list + "clerk_list_allowlist_identifiers" => Some(handlers::list_allowlist_identifiers), + "clerk_create_allowlist_identifier" => Some(handlers::create_allowlist_identifier), + "clerk_delete_allowlist_identifier" => Some(handlers::delete_allowlist_identifier), + "clerk_list_blocklist_identifiers" => Some(handlers::list_blocklist_identifiers), + "clerk_create_blocklist_identifier" => Some(handlers::create_blocklist_identifier), + "clerk_delete_blocklist_identifier" => Some(handlers::delete_blocklist_identifier), + _ => None, + }; + + match handler { + Some(f) => f(args), + None => sdk::err_result(&format!("unknown tool: {tool_name}")), + } +} + +pub(crate) fn clerk_get(path: &str) -> Result { + do_request("GET", path, None) +} + +pub(crate) fn clerk_post( + path: &str, + body: &serde_json::Value, +) -> Result { + do_request("POST", path, Some(body)) +} + +pub(crate) fn clerk_patch( + path: &str, + body: &serde_json::Value, +) -> Result { + do_request("PATCH", path, Some(body)) +} + +pub(crate) fn clerk_delete(path: &str) -> Result { + do_request("DELETE", path, None) +} + +fn do_request( + method: &str, + path: &str, + body: Option<&serde_json::Value>, +) -> Result { + let secret_key = with_config(|c| c.secret_key.clone())?; + + let mut headers = HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {secret_key}")); + headers.insert("Accept".into(), "application/json".into()); + + let body_str = if let Some(b) = body { + headers.insert("Content-Type".into(), "application/json".into()); + serde_json::to_string(b).map_err(|e| format!("clerk: encode request: {e}"))? + } else { + String::new() + }; + + let req = sdk::HttpRequest { + method: method.into(), + url: format!("{API_BASE}{path}"), + headers, + body: body_str, + ..Default::default() + }; + + let resp = sdk::host_http_request(&req)?; + if resp.status >= 400 { + return Err(format!("clerk API error ({}): {}", resp.status, resp.body)); + } + if resp.status == 204 || resp.body.is_empty() { + return Ok(serde_json::json!({"status": "success"})); + } + serde_json::from_str(&resp.body).map_err(|e| format!("clerk: decode response: {e}")) +} + +pub(crate) fn path_escape(s: &str) -> String { + encode_component(s) +} + +pub(crate) fn query_escape(s: &str) -> String { + encode_component(s) +} + +fn encode_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + let c = *b; + let safe = c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~'); + if safe { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +fn compact_spec_map() -> HashMap> { + let mut s: HashMap> = HashMap::new(); + + // Users list — Clerk returns either a bare array or {data, total_count}. + // Spec both shapes so compaction always finds a match. + s.insert( + "clerk_list_users".into(), + vec![ + "total_count".into(), + "data[].id".into(), + "data[].username".into(), + "data[].first_name".into(), + "data[].last_name".into(), + "data[].primary_email_address_id".into(), + "data[].primary_phone_number_id".into(), + "data[].email_addresses[].id".into(), + "data[].email_addresses[].email_address".into(), + "data[].email_addresses[].verification.status".into(), + "data[].phone_numbers[].id".into(), + "data[].phone_numbers[].phone_number".into(), + "data[].banned".into(), + "data[].locked".into(), + "data[].created_at".into(), + "data[].updated_at".into(), + "data[].last_sign_in_at".into(), + "data[].last_active_at".into(), + // Fallback for bare-array responses + "[].id".into(), + "[].username".into(), + "[].first_name".into(), + "[].last_name".into(), + "[].primary_email_address_id".into(), + "[].email_addresses[].email_address".into(), + "[].phone_numbers[].phone_number".into(), + "[].banned".into(), + "[].locked".into(), + "[].created_at".into(), + "[].last_sign_in_at".into(), + "[].last_active_at".into(), + ], + ); + s.insert( + "clerk_list_user_organization_memberships".into(), + vec![ + "total_count".into(), + "data[].id".into(), + "data[].role".into(), + "data[].role_name".into(), + "data[].created_at".into(), + "data[].organization.id".into(), + "data[].organization.name".into(), + "data[].organization.slug".into(), + "data[].organization.members_count".into(), + ], + ); + s.insert( + "clerk_list_sessions".into(), + vec![ + "[].id".into(), + "[].user_id".into(), + "[].client_id".into(), + "[].status".into(), + "[].last_active_at".into(), + "[].expire_at".into(), + "[].abandon_at".into(), + "[].created_at".into(), + "[].latest_activity.country".into(), + "[].latest_activity.city".into(), + "[].latest_activity.is_mobile".into(), + "[].latest_activity.browser_name".into(), + "[].latest_activity.device_type".into(), + ], + ); + s.insert( + "clerk_list_organizations".into(), + vec![ + "total_count".into(), + "data[].id".into(), + "data[].name".into(), + "data[].slug".into(), + "data[].members_count".into(), + "data[].max_allowed_memberships".into(), + "data[].created_by".into(), + "data[].created_at".into(), + "data[].updated_at".into(), + ], + ); + s.insert( + "clerk_list_organization_memberships".into(), + vec![ + "total_count".into(), + "data[].id".into(), + "data[].role".into(), + "data[].role_name".into(), + "data[].created_at".into(), + "data[].public_user_data.user_id".into(), + "data[].public_user_data.identifier".into(), + "data[].public_user_data.first_name".into(), + "data[].public_user_data.last_name".into(), + "data[].organization.id".into(), + "data[].organization.slug".into(), + ], + ); + s.insert( + "clerk_list_organization_invitations".into(), + vec![ + "total_count".into(), + "data[].id".into(), + "data[].email_address".into(), + "data[].role".into(), + "data[].role_name".into(), + "data[].status".into(), + "data[].organization_id".into(), + "data[].created_at".into(), + "data[].updated_at".into(), + ], + ); + s.insert( + "clerk_list_invitations".into(), + vec![ + "[].id".into(), + "[].email_address".into(), + "[].status".into(), + "[].revoked".into(), + "[].created_at".into(), + "[].updated_at".into(), + "[].expires_at".into(), + "[].url".into(), + ], + ); + s.insert( + "clerk_list_allowlist_identifiers".into(), + vec![ + "[].id".into(), + "[].identifier".into(), + "[].identifier_type".into(), + "[].instance_id".into(), + "[].created_at".into(), + "[].updated_at".into(), + ], + ); + s.insert( + "clerk_list_blocklist_identifiers".into(), + vec![ + "[].id".into(), + "[].identifier".into(), + "[].identifier_type".into(), + "[].instance_id".into(), + "[].created_at".into(), + "[].updated_at".into(), + ], + ); + + s +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn tool_definitions_have_required_metadata() { + let defs = tools::tool_definitions(); + assert_eq!(defs.len(), 34); + + let mut seen = HashSet::new(); + for def in defs { + assert!(def.name.starts_with("clerk_"), "{}", def.name); + assert!(!def.description.is_empty(), "{}", def.name); + assert!(seen.insert(def.name)); + } + } + + #[test] + fn entry_point_tools_have_start_here_guidance() { + for def in tools::tool_definitions() { + if matches!( + def.name.as_str(), + "clerk_list_users" + | "clerk_list_sessions" + | "clerk_list_organizations" + | "clerk_list_invitations" + | "clerk_list_allowlist_identifiers" + | "clerk_list_blocklist_identifiers" + ) { + assert!(def.description.contains("Start here"), "{}", def.name); + } + } + } + + #[test] + fn compact_specs_reference_known_tools() { + let defs = tools::tool_definitions() + .into_iter() + .map(|def| def.name) + .collect::>(); + for name in compact_spec_map().keys() { + assert!(defs.contains(name), "{name}"); + } + } + + #[test] + fn unknown_tool_returns_error_result() { + let result = dispatch("clerk_missing_tool", HashMap::new()); + assert!(result.is_error); + assert!(result.data.contains("unknown tool")); + } + + #[test] + fn dispatch_covers_every_tool() { + // No host_http_request available in unit tests, so handlers will return an + // error, but it must NOT be the "unknown tool" error from the dispatch + // fallthrough. Any other error proves the tool name routes to a handler. + for def in tools::tool_definitions() { + let result = dispatch(&def.name, HashMap::new()); + assert!( + !result.data.contains("unknown tool"), + "tool {} is not in dispatch", + def.name + ); + } + } + + #[test] + fn escapes_path_and_query_components() { + assert_eq!(path_escape("user_2abc/def+ghi"), "user_2abc%2Fdef%2Bghi"); + assert_eq!(query_escape("dale@example.com"), "dale%40example.com"); + } +} diff --git a/plugins/clerk/src/tools.rs b/plugins/clerk/src/tools.rs new file mode 100644 index 0000000..150f334 --- /dev/null +++ b/plugins/clerk/src/tools.rs @@ -0,0 +1,376 @@ +use std::collections::HashMap; +use switchboard_guest_sdk::ToolDefinition; + +macro_rules! tool { + ($name:expr, $desc:expr, $params:expr) => { + ToolDefinition { + name: $name.into(), + description: $desc.into(), + parameters: $params, + required: vec![], + } + }; + ($name:expr, $desc:expr, $params:expr, $req:expr) => { + ToolDefinition { + name: $name.into(), + description: $desc.into(), + parameters: $params, + required: $req.iter().map(|s: &&str| s.to_string()).collect(), + } + }; +} + +macro_rules! params { + () => { HashMap::new() }; + ($($k:expr => $v:expr),+ $(,)?) => {{ + let mut m: HashMap = HashMap::new(); + $(m.insert($k.into(), $v.into());)+ + m + }}; +} + +pub fn tool_definitions() -> Vec { + vec![ + // ── Users ──────────────────────────────────────────────────────────── + tool!( + "clerk_list_users", + "Search and list Clerk users by email, phone, username, name, or user ID. Start here for Clerk user management, identity lookup, account audit, finding signed-up users, B2C auth debugging, login history, or banned/locked account review. Covers users, accounts, identities, members, customers.", + params!( + "limit" => "Maximum users to return (default 10, max 500)", + "offset" => "Pagination offset (default 0)", + "order_by" => "Sort field with optional direction, e.g. -created_at, +last_active_at, +last_sign_in_at, +email_address, +username (default -created_at)", + "query" => "Free-text query matched against email, phone, username, first/last name, user ID", + "email_address" => "Comma-separated email addresses to filter by (exact match)", + "phone_number" => "Comma-separated phone numbers to filter by (exact match)", + "username" => "Comma-separated usernames to filter by (exact match)", + "user_id" => "Comma-separated user IDs to filter by", + "external_id" => "Comma-separated external IDs to filter by", + "organization_id" => "Comma-separated org IDs — return users who belong to any of these organizations", + "banned" => "Filter to banned users only (true/false)", + "locked" => "Filter to locked users only (true/false)", + "last_active_at_since" => "Unix epoch ms — return users active since this time" + ) + ), + tool!( + "clerk_get_user", + "Get a Clerk user's full profile, including email addresses, phone numbers, external auth accounts, public/private metadata, and timestamps. Use after list_users when inspecting a specific account.", + params!( + "user_id" => "Clerk user ID (e.g. user_2abc...)" + ), + &["user_id"] + ), + tool!( + "clerk_create_user", + "Create a new Clerk user with email, phone, username, password, name, or external ID. Optionally attach public/private/unsafe metadata.", + params!( + "email_address" => "JSON array string of email addresses, e.g. [\"a@b.com\"]", + "phone_number" => "JSON array string of phone numbers in E.164 format", + "web3_wallet" => "JSON array string of Web3 wallet addresses", + "username" => "Username for the user", + "password" => "Plain-text password (Clerk will hash)", + "password_digest" => "Pre-hashed password digest", + "password_hasher" => "Hash algorithm for password_digest (e.g. bcrypt, argon2i)", + "first_name" => "User's first name", + "last_name" => "User's last name", + "external_id" => "External system ID for this user", + "skip_password_checks" => "Skip password complexity checks (true/false)", + "skip_password_requirement" => "Allow user creation without a password (true/false)", + "public_metadata" => "JSON object string of public metadata", + "private_metadata" => "JSON object string of private metadata", + "unsafe_metadata" => "JSON object string of unsafe metadata", + "created_at" => "Backfill created_at timestamp (RFC3339 or Unix seconds)" + ) + ), + tool!( + "clerk_update_user", + "Update a Clerk user's profile, primary identifier, metadata, password, or activity flags. Use after list_users or get_user.", + params!( + "user_id" => "Clerk user ID", + "first_name" => "Update first name", + "last_name" => "Update last name", + "username" => "Update username", + "primary_email_address_id" => "ID of the email_address record to mark primary", + "primary_phone_number_id" => "ID of the phone_number record to mark primary", + "primary_web3_wallet_id" => "ID of the web3 wallet to mark primary", + "profile_image_id" => "Image ID to use as the profile picture", + "password" => "New password (Clerk will hash)", + "password_digest" => "Pre-hashed password digest", + "password_hasher" => "Hash algorithm for password_digest", + "sign_out_of_other_sessions" => "Sign user out of other active sessions after password change (true/false)", + "external_id" => "External system ID", + "public_metadata" => "JSON object string of public metadata (replaces existing)", + "private_metadata" => "JSON object string of private metadata (replaces existing)", + "unsafe_metadata" => "JSON object string of unsafe metadata (replaces existing)" + ), + &["user_id"] + ), + tool!( + "clerk_delete_user", + "Permanently delete a Clerk user. Use after list_users or get_user when removing an account.", + params!( + "user_id" => "Clerk user ID to delete" + ), + &["user_id"] + ), + tool!( + "clerk_ban_user", + "Ban a Clerk user, preventing all future sign-ins. Reversible via unban_user.", + params!("user_id" => "Clerk user ID to ban"), + &["user_id"] + ), + tool!( + "clerk_unban_user", + "Lift a ban on a Clerk user, restoring their ability to sign in.", + params!("user_id" => "Clerk user ID to unban"), + &["user_id"] + ), + tool!( + "clerk_lock_user", + "Lock a Clerk user out of new sign-ins (without banning). Useful for temporary holds during fraud review.", + params!("user_id" => "Clerk user ID to lock"), + &["user_id"] + ), + tool!( + "clerk_unlock_user", + "Unlock a previously locked Clerk user, restoring sign-in.", + params!("user_id" => "Clerk user ID to unlock"), + &["user_id"] + ), + tool!( + "clerk_list_user_organization_memberships", + "List all Clerk organizations a user belongs to, with their role and organization metadata. Use after list_users to map a user to their tenants/workspaces.", + params!( + "user_id" => "Clerk user ID", + "limit" => "Maximum memberships to return (default 10, max 100)", + "offset" => "Pagination offset" + ), + &["user_id"] + ), + + // ── Sessions ───────────────────────────────────────────────────────── + tool!( + "clerk_list_sessions", + "List Clerk authentication sessions (active sign-ins) for a user or client. Start here for session debugging, auditing who is signed in, revoking suspicious logins, and seeing which clients/devices a user is using. Requires user_id or client_id.", + params!( + "user_id" => "Filter sessions for a specific Clerk user (required if client_id not set)", + "client_id" => "Filter sessions for a specific client/device (required if user_id not set)", + "status" => "Filter by session status: abandoned, active, ended, expired, removed, replaced, revoked" + ) + ), + tool!( + "clerk_get_session", + "Get a Clerk session by ID, including user, client, status, expiry, and latest activity (browser, device, location). Use after list_sessions when debugging a specific sign-in.", + params!("session_id" => "Clerk session ID (e.g. sess_2abc...)"), + &["session_id"] + ), + tool!( + "clerk_revoke_session", + "Revoke a Clerk session, signing the user out of that client/device. Use after list_sessions or get_session for security incident response.", + params!("session_id" => "Clerk session ID to revoke"), + &["session_id"] + ), + + // ── Organizations ──────────────────────────────────────────────────── + tool!( + "clerk_list_organizations", + "List or search Clerk organizations (tenants, workspaces, teams, accounts). Start here for B2B tenant management, customer audit, finding which organizations exist, or onboarding/billing reviews.", + params!( + "limit" => "Maximum organizations to return (default 10, max 500)", + "offset" => "Pagination offset (default 0)", + "include_members_count" => "Include each organization's member count (true/false)", + "order_by" => "Sort field with optional direction, e.g. -created_at, +name, +members_count (default -created_at)", + "query" => "Free-text query matched against organization name, slug, or ID", + "user_id" => "Comma-separated user IDs — return organizations that any of these users belong to" + ) + ), + tool!( + "clerk_get_organization", + "Get a Clerk organization by ID or slug, including members count, max allowed memberships, creator, metadata, and timestamps. Use after list_organizations.", + params!( + "organization_id" => "Organization ID (e.g. org_2abc...) or slug", + "include_members_count" => "Include the organization's member count (true/false)" + ), + &["organization_id"] + ), + tool!( + "clerk_create_organization", + "Create a new Clerk organization (tenant, workspace, team) with a name, optional slug, creator user, and metadata.", + params!( + "name" => "Organization display name", + "created_by" => "Clerk user ID who will be the initial owner", + "slug" => "URL-safe slug (auto-generated if omitted)", + "max_allowed_memberships" => "Cap on total members (0 or omit for unlimited)", + "public_metadata" => "JSON object string of public metadata", + "private_metadata" => "JSON object string of private metadata" + ), + &["name", "created_by"] + ), + tool!( + "clerk_update_organization", + "Update a Clerk organization's name, slug, member cap, or metadata. Use after list_organizations or get_organization.", + params!( + "organization_id" => "Organization ID or slug", + "name" => "New organization name", + "slug" => "New URL-safe slug", + "max_allowed_memberships" => "New cap on total members (0 for unlimited)", + "admin_delete_enabled" => "Allow admins to delete the organization (true/false)", + "public_metadata" => "JSON object string of public metadata (replaces existing)", + "private_metadata" => "JSON object string of private metadata (replaces existing)" + ), + &["organization_id"] + ), + tool!( + "clerk_delete_organization", + "Permanently delete a Clerk organization. Removes all memberships and invitations.", + params!("organization_id" => "Organization ID or slug to delete"), + &["organization_id"] + ), + tool!( + "clerk_list_organization_memberships", + "List members of a Clerk organization with their role and user data. Use after list_organizations to see who belongs to a tenant/workspace/team.", + params!( + "organization_id" => "Organization ID or slug", + "limit" => "Maximum memberships to return (default 10, max 500)", + "offset" => "Pagination offset", + "order_by" => "Sort field, e.g. -created_at, +last_active_at, +first_name" + ), + &["organization_id"] + ), + tool!( + "clerk_create_organization_membership", + "Add an existing Clerk user to an organization with a role. Use to invite/onboard team members programmatically when they already have an account.", + params!( + "organization_id" => "Organization ID or slug", + "user_id" => "Clerk user ID to add", + "role" => "Role for the new member (e.g. admin, basic_member, or a custom role key)" + ), + &["organization_id", "user_id", "role"] + ), + tool!( + "clerk_update_organization_membership", + "Change a Clerk organization member's role (e.g. promote to admin).", + params!( + "organization_id" => "Organization ID or slug", + "user_id" => "Clerk user ID of the member", + "role" => "New role key" + ), + &["organization_id", "user_id", "role"] + ), + tool!( + "clerk_delete_organization_membership", + "Remove a user from a Clerk organization. Use after list_organization_memberships.", + params!( + "organization_id" => "Organization ID or slug", + "user_id" => "Clerk user ID of the member to remove" + ), + &["organization_id", "user_id"] + ), + tool!( + "clerk_list_organization_invitations", + "List pending, accepted, or revoked invitations to a Clerk organization. Use to audit outstanding invites before resending or revoking.", + params!( + "organization_id" => "Organization ID or slug", + "limit" => "Maximum invitations to return (default 10, max 500)", + "offset" => "Pagination offset", + "status" => "Comma-separated statuses to filter: pending, accepted, revoked" + ), + &["organization_id"] + ), + tool!( + "clerk_create_organization_invitation", + "Invite a user by email to join a Clerk organization with a role. Sends an email and creates a pending invitation.", + params!( + "organization_id" => "Organization ID or slug", + "email_address" => "Recipient email address", + "role" => "Role to grant on acceptance (e.g. admin, basic_member, or a custom role key)", + "inviter_user_id" => "Clerk user ID of the inviter (shown in the invitation email)", + "redirect_url" => "URL to send the recipient to after accepting", + "public_metadata" => "JSON object string of public metadata", + "private_metadata" => "JSON object string of private metadata" + ), + &["organization_id", "email_address", "role"] + ), + tool!( + "clerk_revoke_organization_invitation", + "Revoke a pending Clerk organization invitation. Use after list_organization_invitations.", + params!( + "organization_id" => "Organization ID or slug", + "invitation_id" => "Invitation ID to revoke", + "requesting_user_id" => "Clerk user ID performing the revoke (for audit)" + ), + &["organization_id", "invitation_id"] + ), + + // ── Invitations (instance-level) ───────────────────────────────────── + tool!( + "clerk_list_invitations", + "List Clerk instance-level invitations (not tied to an organization). Start here for auditing outstanding sign-up invites sent to email addresses.", + params!( + "limit" => "Maximum invitations to return (default 10, max 500)", + "offset" => "Pagination offset", + "status" => "Filter by status: pending, accepted, revoked", + "query" => "Free-text query matched against the invitation email address", + "order_by" => "Sort field, e.g. -created_at, -updated_at" + ) + ), + tool!( + "clerk_create_invitation", + "Send a Clerk instance-level invitation to an email address. The recipient gets a sign-up link.", + params!( + "email_address" => "Recipient email address", + "redirect_url" => "URL to send the recipient to after accepting", + "notify" => "Send the invitation email (true/false, default true)", + "ignore_existing" => "Don't error if an invitation already exists for this email (true/false)", + "expires_in_days" => "Invitation lifetime in days", + "template_slug" => "Specific invitation email template slug to use", + "public_metadata" => "JSON object string of public metadata" + ), + &["email_address"] + ), + tool!( + "clerk_revoke_invitation", + "Revoke a pending Clerk instance-level invitation. Use after list_invitations.", + params!("invitation_id" => "Invitation ID to revoke"), + &["invitation_id"] + ), + + // ── Allow / Block list ─────────────────────────────────────────────── + tool!( + "clerk_list_allowlist_identifiers", + "List identifiers (emails, phones, domains) on the Clerk sign-up allow list. Start here when auditing who is allowed to register for the application.", + params!() + ), + tool!( + "clerk_create_allowlist_identifier", + "Add an email address, phone number, or domain (e.g. @example.com) to the Clerk sign-up allow list.", + params!( + "identifier" => "Email address, E.164 phone number, or @domain to allow", + "notify" => "Send a notification to the identifier when added (true/false)" + ), + &["identifier"] + ), + tool!( + "clerk_delete_allowlist_identifier", + "Remove an identifier from the Clerk sign-up allow list.", + params!("identifier_id" => "Allowlist identifier ID returned by list_allowlist_identifiers"), + &["identifier_id"] + ), + tool!( + "clerk_list_blocklist_identifiers", + "List identifiers (emails, phones, domains) on the Clerk sign-up block list. Start here when auditing which addresses are blocked from registering.", + params!() + ), + tool!( + "clerk_create_blocklist_identifier", + "Add an email address, phone number, or domain (e.g. @example.com) to the Clerk sign-up block list.", + params!("identifier" => "Email address, E.164 phone number, or @domain to block"), + &["identifier"] + ), + tool!( + "clerk_delete_blocklist_identifier", + "Remove an identifier from the Clerk sign-up block list.", + params!("identifier_id" => "Blocklist identifier ID returned by list_blocklist_identifiers"), + &["identifier_id"] + ), + ] +}