//! `UsersService` — the v1.1.8 data-plane user management contract. //! //! Scripts get `users::*` for CRUD on per-app end-users (distinct from //! the control-plane operators in `admin_users`), session-based auth //! (`login` / `verify` / `logout`), email verification, password reset, //! invitations, and string-tagged per-app roles. //! //! Lives in `picloud-shared` so the Rhai bridge in `executor-core` and //! the impl in `manager-core` share one trait. The impl //! (`manager-core::users_service::UsersServiceImpl`) wires the //! Postgres-backed repos; `picloud-shared` stays free of the sqlx //! dependency. //! //! Every method that takes `&SdkCallCx` derives `app_id` from //! `cx.app_id` — never from a script-passed argument. The //! `verify_session_for_realtime` method takes `app_id` explicitly //! because it's invoked from the realtime authority (no script //! execution context). use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx}; /// Public app-user record returned by `get` / `find_by_email` / `verify` /// / `list` / etc. Never carries the password hash. The `roles` field /// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage; /// pre-commit-9 it's always empty). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppUser { pub id: AppUserId, pub app_id: AppId, pub email: String, pub display_name: Option, pub email_verified_at: Option>, pub last_login_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, pub roles: Vec, } /// Pending invitation row returned by the admin invitations API. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Invitation { pub id: InvitationId, pub app_id: AppId, pub email: String, pub display_name: Option, pub roles: Vec, pub created_at: DateTime, pub expires_at: DateTime, } /// Input for `users::create` and the admin POST endpoint. #[derive(Debug, Clone)] pub struct CreateUserInput { pub email: String, pub password: String, pub display_name: Option, } /// Input for `users::update` / admin PATCH. v1.1.8 patches only /// display_name; password changes go through password reset, email is /// immutable post-create (a v1.2 concern). #[derive(Debug, Clone, Default)] pub struct UpdateUserInput { pub display_name: Option>, } /// Pagination knobs for `users::list`. The cursor is an opaque /// `_` string from a previous page's `next_cursor`; /// `None` is "first page". F-P-012: the id half is the tiebreaker that /// prevents skip/duplicate at a created_at-equal page boundary. #[derive(Debug, Clone, Default)] pub struct UsersListOpts { pub cursor: Option, pub limit: Option, } #[derive(Debug, Clone)] pub struct UsersListPage { pub items: Vec, pub next_cursor: Option, } /// Newly-minted session — returned exactly once by `users::login` and /// `users::accept_invite`. The raw token goes back to the caller; only /// the hash hits storage. #[derive(Debug, Clone)] pub struct GeneratedSession { pub token: String, pub user: AppUser, pub expires_at: DateTime, } /// Outcome of `users::accept_invite`: the freshly-created user plus a /// session token so the script can return both in one round-trip. #[derive(Debug, Clone)] pub struct GeneratedAccept { pub user: AppUser, pub session: GeneratedSession, } /// Email template fields supplied by the script to v1.1.8 email-tied /// flows (`send_verification_email`, `request_password_reset`, `invite`). /// The script owns the body; PiCloud only injects `?token=` into /// the `link_base`. /// /// `from` is required because the underlying `EmailService::send` needs /// an envelope sender — the brief example omitted it for brevity, but /// the service can't synthesize a sensible default. Documented as a /// shape deviation in HANDBACK §7. #[derive(Debug, Clone)] pub struct EmailTemplateOpts { pub link_base: String, pub from: String, pub subject: String, /// Body template with `{link}` substituted for the /// `link_base?token=` URL. Plain text body — the v1.1.7 /// hybrid email design lets richer templates land in userland. pub body_template: String, } /// Extra fields for `users::invite` on top of the email template: /// optional pre-staged display name + roles applied on accept. #[derive(Debug, Clone, Default)] pub struct InviteOpts { pub template: Option, pub display_name: Option, pub roles: Vec, } /// Failure modes surfaced to the Rhai bridge and the admin HTTP layer. /// Variants are mapped to stable string codes by the SDK bridge so /// scripts can `try`/`catch` and switch on the code; admin handlers /// map to HTTP status codes. #[derive(Debug, Error)] pub enum UsersError { /// Caller principal lacked the required capability. Only raised /// when `cx.principal.is_some()` (script-as-gate semantics). #[error("forbidden")] Forbidden, /// Looked-up user does not exist (or, more precisely, is not /// visible from `cx.app_id`). #[error("not found")] NotFound, /// `users::create` collided with an existing `(app_id, lower(email))` /// row. Surfaced to script-land as a distinguishable code so a /// sign-up form can react; never distinguishes "exists" vs /// "doesn't exist" through any timing-observable side channel. #[error("email already in use")] DuplicateEmail, /// Input field failed validation (e.g., empty email, password /// shorter than 8 characters, malformed shape). #[error("invalid: {0}")] Invalid(String), /// Email-tied flow attempted but no SMTP relay is configured. Maps /// to the same script-side code as `EmailError::NotConfigured` so /// scripts already handling the email-disabled mode don't need /// special cases. #[error("email is not configured")] EmailNotConfigured, /// Underlying email send failed (transport rejected, bad address, /// too large). Wraps the EmailError message for diagnostics. #[error("email transport: {0}")] EmailTransport(String), /// Email-tied surface (verification / password reset / invite) was /// rate-limited. Either the per-(app, recipient) burst limit or the /// per-app daily cap was exceeded. Surfaces silently to script-land /// in the reset path (to avoid an existence-leak side channel) but /// is raised explicitly to admin-mediated callers. #[error("email send rate-limited")] EmailRateLimited, /// Underlying storage error. Surfaces to script-land as a generic /// runtime error; the message is logged server-side for forensics. #[error("backend error: {0}")] Backend(String), } #[async_trait] pub trait UsersService: Send + Sync { // ---- CRUD ---- async fn create(&self, cx: &SdkCallCx, input: CreateUserInput) -> Result; async fn get(&self, cx: &SdkCallCx, id: AppUserId) -> Result, UsersError>; async fn find_by_email( &self, cx: &SdkCallCx, 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`. /// /// Enumeration note: that bit is the same one a register form already /// leaks ("email taken"), but this probe is cheaper (no password hash), /// has no side effect (a `create` miss writes a junk row), and is not /// rate limited (there is no built-in throttle/CAPTCHA primitive). A /// script exposing it on an anonymous route inherits account-enumeration /// risk and must add its own `kv`-based throttle if that matters. async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result; async fn update( &self, cx: &SdkCallCx, id: AppUserId, patch: UpdateUserInput, ) -> Result; async fn delete(&self, cx: &SdkCallCx, id: AppUserId) -> Result; async fn list(&self, cx: &SdkCallCx, opts: UsersListOpts) -> Result; // ---- Auth ---- /// Email + password login. Returns `Some(session)` on success, /// `None` on bad credentials (same wall-clock cost on bad-email /// vs bad-password via the timing-flat dummy Argon2 path). async fn login( &self, cx: &SdkCallCx, email: &str, password: &str, ) -> Result, UsersError>; /// Verify a session token. Returns the owning user on success, /// `None` on missing / expired / revoked / cross-app token. Bumps /// the sliding-window TTL on success (capped at the absolute cap). async fn verify(&self, cx: &SdkCallCx, token: &str) -> Result, UsersError>; /// Logout — revokes the session. No-op for already-revoked / /// already-expired tokens. async fn logout(&self, cx: &SdkCallCx, token: &str) -> Result<(), UsersError>; /// Realtime SSE entry point. Same DB lookup + sliding-window bump /// as `verify`, but takes `app_id` explicitly because the /// `RealtimeAuthority` is not inside a script execution. Returns /// `None` on missing / expired / revoked / cross-app token. async fn verify_session_for_realtime( &self, app_id: AppId, token: &str, ) -> Result, UsersError>; // ---- Email verification (commit 5) ---- async fn send_verification_email( &self, cx: &SdkCallCx, user_id: AppUserId, opts: EmailTemplateOpts, ) -> Result<(), UsersError>; async fn verify_email( &self, cx: &SdkCallCx, token: &str, ) -> Result, UsersError>; // ---- Password reset (commit 6) ---- async fn request_password_reset( &self, cx: &SdkCallCx, email: &str, opts: EmailTemplateOpts, ) -> Result<(), UsersError>; async fn complete_password_reset( &self, cx: &SdkCallCx, token: &str, new_password: &str, ) -> Result, UsersError>; // ---- Invitations (commit 7) ---- async fn invite(&self, cx: &SdkCallCx, email: &str, opts: InviteOpts) -> Result<(), UsersError>; async fn accept_invite( &self, cx: &SdkCallCx, token: &str, password: &str, display_name: Option, ) -> Result, UsersError>; // ---- Roles (commit 8) ---- async fn add_role( &self, cx: &SdkCallCx, user_id: AppUserId, role: &str, ) -> Result<(), UsersError>; async fn remove_role( &self, cx: &SdkCallCx, user_id: AppUserId, role: &str, ) -> Result; async fn has_role( &self, cx: &SdkCallCx, user_id: AppUserId, role: &str, ) -> Result; // ---- Admin-mediated (no cx; called from the admin HTTP layer // with an explicit principal) ---- /// Admin clicks "reset password"; returns a one-shot token the /// admin can paste into a manual reset link. Does NOT send an /// email — that's the SDK's `request_password_reset` path. async fn admin_reset_password_token( &self, principal: &Principal, app_id: AppId, user_id: AppUserId, ) -> Result; /// Admin clicks "revoke all sessions"; returns the count of /// sessions revoked (zero for already-quiet users). async fn admin_revoke_all_sessions( &self, principal: &Principal, app_id: AppId, user_id: AppUserId, ) -> Result; /// Admin issues an invitation from the dashboard (distinct from /// the SDK `users::invite` path, which takes an SdkCallCx tied to /// the calling script). Returns the created invitation so the /// admin UI can render it without a re-fetch. async fn admin_create_invitation( &self, principal: &Principal, app_id: AppId, email: &str, opts: InviteOpts, ) -> Result; /// Admin lists pending invitations for the app's invitations tab. async fn list_invitations( &self, principal: &Principal, app_id: AppId, ) -> Result, UsersError>; /// Admin revokes a pending invitation (the row is deleted; the /// already-mailed link becomes inert). async fn revoke_invitation( &self, principal: &Principal, app_id: AppId, invite_id: InvitationId, ) -> Result; } /// Stub used by tests that build a `Services` bundle without a backing /// users impl. Every call returns `UsersError::Backend("noop users /// service")` so tests fail loudly instead of silently passing through. #[derive(Debug, Default, Clone, Copy)] pub struct NoopUsersService; const NOOP_MSG: &str = "noop users service"; #[async_trait] impl UsersService for NoopUsersService { async fn create( &self, _cx: &SdkCallCx, _input: CreateUserInput, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn get(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn find_by_email( &self, _cx: &SdkCallCx, _email: &str, ) -> 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, _id: AppUserId, _patch: UpdateUserInput, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn delete(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn list( &self, _cx: &SdkCallCx, _opts: UsersListOpts, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn login( &self, _cx: &SdkCallCx, _email: &str, _password: &str, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn verify(&self, _cx: &SdkCallCx, _token: &str) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn logout(&self, _cx: &SdkCallCx, _token: &str) -> Result<(), UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn verify_session_for_realtime( &self, _app_id: AppId, _token: &str, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn send_verification_email( &self, _cx: &SdkCallCx, _user_id: AppUserId, _opts: EmailTemplateOpts, ) -> Result<(), UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn verify_email( &self, _cx: &SdkCallCx, _token: &str, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn request_password_reset( &self, _cx: &SdkCallCx, _email: &str, _opts: EmailTemplateOpts, ) -> Result<(), UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn complete_password_reset( &self, _cx: &SdkCallCx, _token: &str, _new_password: &str, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn invite( &self, _cx: &SdkCallCx, _email: &str, _opts: InviteOpts, ) -> Result<(), UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn accept_invite( &self, _cx: &SdkCallCx, _token: &str, _password: &str, _display_name: Option, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn add_role( &self, _cx: &SdkCallCx, _user_id: AppUserId, _role: &str, ) -> Result<(), UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn remove_role( &self, _cx: &SdkCallCx, _user_id: AppUserId, _role: &str, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn has_role( &self, _cx: &SdkCallCx, _user_id: AppUserId, _role: &str, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn admin_reset_password_token( &self, _principal: &Principal, _app_id: AppId, _user_id: AppUserId, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn admin_revoke_all_sessions( &self, _principal: &Principal, _app_id: AppId, _user_id: AppUserId, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn admin_create_invitation( &self, _principal: &Principal, _app_id: AppId, _email: &str, _opts: InviteOpts, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } async fn list_invitations( &self, _principal: &Principal, _app_id: AppId, ) -> Result, UsersError> { Err(UsersError::Backend(NOOP_MSG.into())) } async fn revoke_invitation( &self, _principal: &Principal, _app_id: AppId, _invite_id: InvitationId, ) -> Result { Err(UsersError::Backend(NOOP_MSG.into())) } }