"""User authentication primitives. Cassandra is **passwordless**. Every login is an email-OTP round-trip (see app.services.otp_service + app.services.email_service). This module just handles user-row lookup and create-on-first-sight. The trade-off (see Phase G plan in tasks/todo.md): - Server holds: email, tier, AI cost ledger. No portfolio, no broker keys. - Loss of password gives up nothing of value to protect; gains: no password-reset flows, no hash rotation, no stuffing/breach exposure. - Every successful session is by construction proof of email control. """ from __future__ import annotations from email_validator import EmailNotValidError, validate_email from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow from app.models import User class AuthError(Exception): """Raised on bad input. The message is safe to surface to the user.""" def _validate_email_or_raise(email: str) -> str: try: info = validate_email(email, check_deliverability=False) return info.normalized.lower() except EmailNotValidError as e: raise AuthError(f"Invalid email: {e}") async def get_user(session: AsyncSession, user_id: int) -> User | None: return (await session.execute( select(User).where(User.id == user_id) )).scalar_one_or_none() async def get_user_by_email(session: AsyncSession, email: str) -> User | None: email = email.strip().lower() return (await session.execute( select(User).where(User.email == email) )).scalar_one_or_none() async def get_or_create_user( session: AsyncSession, email: str, *, create_if_missing: bool = True, tier: str = "free", ) -> User: """Look up the user by email; create if absent and create_if_missing. Raises AuthError on malformed email, or if create_if_missing=False and the email is unknown. Callers should set create_if_missing=False when CASSANDRA_SIGNUP_ENABLED is false — i.e., the operator is running a closed deployment.""" email = _validate_email_or_raise(email) user = await get_user_by_email(session, email) if user is not None: return user if not create_if_missing: raise AuthError("Sign-ups are currently disabled. Ask the operator.") user = User(email=email, tier=tier, settings_json={}, created_at=utcnow()) session.add(user) await session.commit() await session.refresh(user) return user