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:
MechaCat02
2026-06-12 20:43:18 +02:00
parent ae2134e62d
commit 7ffbb8de87
4 changed files with 64 additions and 1 deletions

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,