diff --git a/crates/executor-core/src/sdk/users.rs b/crates/executor-core/src/sdk/users.rs index dc22466..5f59d39 100644 --- a/crates/executor-core/src/sdk/users.rs +++ b/crates/executor-core/src/sdk/users.rs @@ -4,7 +4,12 @@ //! // 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 () +//! 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. //! users::update(id, #{ display_name: "Alicia" }); //! let removed = users::delete(id); // bool //! let page = users::list(#{ "$limit": 50, cursor: () }); @@ -56,6 +61,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc, cx: &Arc ); } +/// `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(); diff --git a/crates/manager-core/src/realtime_authority.rs b/crates/manager-core/src/realtime_authority.rs index ae4ac26..49fac35 100644 --- a/crates/manager-core/src/realtime_authority.rs +++ b/crates/manager-core/src/realtime_authority.rs @@ -398,6 +398,13 @@ mod tests { ) -> Result, picloud_shared::UsersError> { unimplemented!() } + async fn email_available( + &self, + _: &picloud_shared::SdkCallCx, + _: &str, + ) -> Result { + unimplemented!() + } async fn update( &self, _: &picloud_shared::SdkCallCx, diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index 80f50b8..06b21a9 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -516,6 +516,23 @@ impl UsersService for UsersServiceImpl { Ok(Some(Self::to_app_user(row, roles))) } + async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result { + // Gated like `create` (the registration write path) so the probe + // is available exactly where self-serve registration is — and, + // crucially, WITHOUT find_by_email's anonymous-principal rejection. + // It returns only a boolean, so it leaks no more than `create`'s + // own uniqueness error already does (F-S-003: enumeration risk is + // bounded to "exists / doesn't"). + self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) + .await?; + let normalized = validate_email(email)?; + Ok(self + .users + .find_by_email(cx.app_id, &normalized) + .await? + .is_none()) + } + async fn update( &self, cx: &SdkCallCx, diff --git a/crates/shared/src/users.rs b/crates/shared/src/users.rs index 8f528df..3ae0a74 100644 --- a/crates/shared/src/users.rs +++ b/crates/shared/src/users.rs @@ -200,6 +200,15 @@ pub trait UsersService: Send + Sync { email: &str, ) -> Result, UsersError>; + /// Anonymous-safe existence probe for self-serve registration. + /// Returns `true` when the email is free to register, `false` when + /// it's taken. Unlike [`find_by_email`](Self::find_by_email) (which + /// requires an authenticated principal to prevent user enumeration), + /// this leaks only a single boolean — no id, profile, or timing + /// distinction beyond "exists / doesn't" — so a public registration + /// script can pre-check before calling `create`. + async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result; + async fn update( &self, cx: &SdkCallCx, @@ -387,6 +396,9 @@ impl UsersService for NoopUsersService { ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } + async fn email_available(&self, _cx: &SdkCallCx, _email: &str) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } async fn update( &self, _cx: &SdkCallCx,