"""Identity tables: users, invites, sessions, api_tokens. See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and db.py's module docstring for how isolation between users is enforced now that there's no RLS to do it at the database level (docs/DECISIONS.md D15). Types are deliberately dialect-generic (`sqlalchemy.Uuid`, `JSON`, plain `String` for the IP column) rather than the `postgresql.*` variants Phase 0 originally used — this schema now targets SQLite only, but there's no reason to hand-tie it to a Postgres-only type where a portable one works identically. See models/base.py for why timestamps need a custom type at all. """ from datetime import UTC, datetime from uuid import UUID from sqlalchemy import JSON, ForeignKey, LargeBinary, String, Text, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from velodrome.ids import new_id from velodrome.models.base import Base def _now_utc() -> datetime: return datetime.now(UTC) # The only two values `users.role` and `invites.role` are ever set to. Named constants so the set # is discoverable from one place: `velodrome.cli`'s create-admin writes ROLE_ADMIN, and # registration copies whatever role the redeemed invite carries. Nothing *enforces* a role yet — # no admin-only endpoint exists (docs/DECISIONS.md D18) — and the column stays a plain string # rather than a DB-level enum or CHECK constraint so adding a third role later is an application # change, not a migration. ROLE_MEMBER = "member" ROLE_ADMIN = "admin" class User(Base): __tablename__ = "users" id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) display_name: Mapped[str] = mapped_column(String(200), nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False) role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER) timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC") # Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query. unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial") is_active: Mapped[bool] = mapped_column(nullable=False, default=True) created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc) sessions: Mapped[list["Session"]] = relationship(back_populates="user") api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user") class Invite(Base): __tablename__ = "invites" id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id) # sha256 digest of the invite code. The code itself is never stored anywhere — see # auth/service.py. 32 bytes for sha256. code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False) created_by: Mapped[UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False ) email: Mapped[str | None] = mapped_column(String(320), nullable=True) role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER) expires_at: Mapped[datetime] = mapped_column(nullable=False) max_uses: Mapped[int] = mapped_column(nullable=False, default=1) used_count: Mapped[int] = mapped_column(nullable=False, default=0) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) class Session(Base): __tablename__ = "sessions" id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id) user_id: Mapped[UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) # sha256 of the opaque bearer token. The token itself is returned to the client exactly once, # at login, and never stored — see auth/security.py. token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False) client: Mapped[str] = mapped_column(String(20), nullable=False, default="web") user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) # Plain string, not a native INET type — SQLite has no such type, and app code never queries # or indexes on structure within the address, only stores/displays it. ip: Mapped[str | None] = mapped_column(String(45), nullable=True) created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc) last_seen_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc) expires_at: Mapped[datetime] = mapped_column(nullable=False) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) user: Mapped["User"] = relationship(back_populates="sessions") class ApiToken(Base): __tablename__ = "api_tokens" id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id) user_id: Mapped[UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) name: Mapped[str] = mapped_column(String(200), nullable=False) token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False) # JSON, not ARRAY(String) — SQLite has no array type. Stored as a JSON-encoded TEXT column; # SQLAlchemy handles the (de)serialization transparently. scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) last_used_at: Mapped[datetime | None] = mapped_column(nullable=True) expires_at: Mapped[datetime | None] = mapped_column(nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) user: Mapped["User"] = relationship(back_populates="api_tokens")