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
|
||||
//! 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<Sdk
|
||||
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);
|
||||
@@ -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>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
|
||||
@@ -398,6 +398,13 @@ mod tests {
|
||||
) -> Result<Option<picloud_shared::AppUser>, picloud_shared::UsersError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn email_available(
|
||||
&self,
|
||||
_: &picloud_shared::SdkCallCx,
|
||||
_: &str,
|
||||
) -> Result<bool, picloud_shared::UsersError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_: &picloud_shared::SdkCallCx,
|
||||
|
||||
@@ -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<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(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
|
||||
@@ -200,6 +200,15 @@ pub trait UsersService: Send + Sync {
|
||||
email: &str,
|
||||
) -> 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(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
@@ -387,6 +396,9 @@ impl UsersService for NoopUsersService {
|
||||
) -> Result<Option<AppUser>, UsersError> {
|
||||
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(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
|
||||
Reference in New Issue
Block a user