"""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.legal import ACKNOWLEDGEMENT_VERSION from app.models import User, UserAcknowledgement 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 async def has_acknowledged_current( session: AsyncSession, user: User, ) -> bool: """True iff ``user`` has a ``user_acknowledgements`` row at the currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit on (user_id, version); fast enough to run on every sign-in.""" row = (await session.execute( select(UserAcknowledgement.id) .where(UserAcknowledgement.user_id == user.id) .where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION) .limit(1) )).scalar_one_or_none() return row is not None async def record_acknowledgement( session: AsyncSession, user: User, lang: str, ) -> None: """Insert one ``user_acknowledgements`` row at the current version, recording the language the user actually saw. The caller is expected to gate on ``has_acknowledged_current`` first — this helper does not de-duplicate so the audit trail can carry repeated acceptances if a future caller wants them.""" session.add(UserAcknowledgement( user_id=user.id, version=ACKNOWLEDGEMENT_VERSION, lang=lang, accepted_at=utcnow(), )) await session.commit()