feat(sdk): users::email_available for anonymous registration pre-check (S1)
`users::find_by_email` requires an authenticated principal (F-S-003, anti-enumeration), so the natural check-then-create register pattern 502s for anonymous public scripts. Add `users::email_available(email) -> bool`, gated like `create` (the registration write path) but without the anonymous rejection: it leaks only a single boolean — no more than `create`'s own uniqueness error already does — so self-serve register scripts can pre-check. Also document find_by_email's principal requirement in the SDK doc-comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,12 @@
|
|||||||
//! // CRUD
|
//! // CRUD
|
||||||
//! let u = users::create(#{ email: "a@b", password: "hunter22a", display_name: "Alice" });
|
//! let u = users::create(#{ email: "a@b", password: "hunter22a", display_name: "Alice" });
|
||||||
//! let u = users::get(id); // map or ()
|
//! 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" });
|
//! users::update(id, #{ display_name: "Alicia" });
|
||||||
//! let removed = users::delete(id); // bool
|
//! let removed = users::delete(id); // bool
|
||||||
//! let page = users::list(#{ "$limit": 50, cursor: () });
|
//! let page = users::list(#{ "$limit": 50, cursor: () });
|
||||||
@@ -56,6 +61,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
bind_create(&mut module, &svc, &cx);
|
bind_create(&mut module, &svc, &cx);
|
||||||
bind_get(&mut module, &svc, &cx);
|
bind_get(&mut module, &svc, &cx);
|
||||||
bind_find_by_email(&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_update(&mut module, &svc, &cx);
|
||||||
bind_delete(&mut module, &svc, &cx);
|
bind_delete(&mut module, &svc, &cx);
|
||||||
bind_list(&mut module, &svc, &cx);
|
bind_list(&mut module, &svc, &cx);
|
||||||
@@ -136,6 +142,27 @@ fn bind_find_by_email(module: &mut Module, svc: &Arc<dyn UsersService>, 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<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"email_available",
|
||||||
|
move |email: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
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<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
fn bind_update(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ mod tests {
|
|||||||
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn email_available(
|
||||||
|
&self,
|
||||||
|
_: &picloud_shared::SdkCallCx,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<bool, picloud_shared::UsersError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
_: &picloud_shared::SdkCallCx,
|
_: &picloud_shared::SdkCallCx,
|
||||||
|
|||||||
@@ -516,6 +516,23 @@ impl UsersService for UsersServiceImpl {
|
|||||||
Ok(Some(Self::to_app_user(row, roles)))
|
Ok(Some(Self::to_app_user(row, roles)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result<bool, UsersError> {
|
||||||
|
// 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(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
|
|||||||
@@ -200,6 +200,15 @@ pub trait UsersService: Send + Sync {
|
|||||||
email: &str,
|
email: &str,
|
||||||
) -> Result<Option<AppUser>, UsersError>;
|
) -> Result<Option<AppUser>, 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<bool, UsersError>;
|
||||||
|
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
@@ -387,6 +396,9 @@ impl UsersService for NoopUsersService {
|
|||||||
) -> Result<Option<AppUser>, UsersError> {
|
) -> Result<Option<AppUser>, UsersError> {
|
||||||
Err(UsersError::Backend(NOOP_MSG.into()))
|
Err(UsersError::Backend(NOOP_MSG.into()))
|
||||||
}
|
}
|
||||||
|
async fn email_available(&self, _cx: &SdkCallCx, _email: &str) -> Result<bool, UsersError> {
|
||||||
|
Err(UsersError::Backend(NOOP_MSG.into()))
|
||||||
|
}
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
_cx: &SdkCallCx,
|
||||||
|
|||||||
Reference in New Issue
Block a user