diff --git a/api/alembic/versions/002_better_auth_tables.py b/api/alembic/versions/002_better_auth_tables.py new file mode 100644 index 0000000..aa5dd93 --- /dev/null +++ b/api/alembic/versions/002_better_auth_tables.py @@ -0,0 +1,101 @@ +"""Add Better-Auth tables and extend users table. + +Creates sessions, accounts, and verifications tables for Better-Auth. +Adds email_verified and image columns to existing users table. +Migrates password hashes from users.hashed_password to accounts.password. + +Revision ID: 002_better_auth_tables +Revises: 001_encrypt_session_data +Create Date: 2026-03-28 +""" + +import sqlalchemy as sa +from sqlalchemy import text + +from alembic import op + +revision = "002_better_auth_tables" +down_revision = "001_encrypt_session_data" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- Extend users table for Better-Auth compatibility --- + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) + op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) + + # --- Create sessions table --- + op.create_table( + "sessions", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ip_address", sa.Text(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_sessions_token", "sessions", ["token"], unique=True) + op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) + + # --- Create accounts table --- + op.create_table( + "accounts", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("account_id", sa.Text(), nullable=False), + sa.Column("provider_id", sa.Text(), nullable=False), + sa.Column("access_token", sa.Text(), nullable=True), + sa.Column("refresh_token", sa.Text(), nullable=True), + sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("scope", sa.Text(), nullable=True), + sa.Column("id_token", sa.Text(), nullable=True), + sa.Column("password", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_accounts_user_id", "accounts", ["user_id"]) + + # --- Create verifications table --- + op.create_table( + "verifications", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("identifier", sa.Text(), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + # --- Migrate existing password hashes to accounts table --- + # For each user with a hashed_password, create a 'credential' account row + conn = op.get_bind() + users = conn.execute( + text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL") + ).fetchall() + + for user_id, hashed_password in users: + user_id_str = str(user_id) + conn.execute( + text( + "INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) " + "VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())" + ), + {"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password}, + ) + + +def downgrade() -> None: + op.drop_table("verifications") + op.drop_table("accounts") + op.drop_index("ix_sessions_user_id", table_name="sessions") + op.drop_index("ix_sessions_token", table_name="sessions") + op.drop_table("sessions") + op.drop_column("users", "image") + op.drop_column("users", "email_verified") diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..75ff58a --- /dev/null +++ b/common/README.md @@ -0,0 +1,28 @@ +# CartSnitch Common + +Shared models, schemas, and utilities for CartSnitch services. + +## Test Users + +The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT. + +| Email | Password | Display Name | Notes | +|---|---|---|---| +| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. | + +### Running the Seed + +```bash +# Install with seed dependencies +pip install -e "cartsnitch-common[seed]" + +# Run (requires CARTSNITCH_DATABASE_URL_SYNC) +CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \ + cartsnitch-seed +``` + +### Architecture + +- **Models** live in `src/cartsnitch_common/models/` +- **Alembic migrations** run via the `api` service (`api/alembic/`) +- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point) diff --git a/common/pyproject.toml b/common/pyproject.toml index ee348c5..5aa0309 100644 --- a/common/pyproject.toml +++ b/common/pyproject.toml @@ -27,6 +27,7 @@ dev = [ ] seed = [ "faker>=33.0,<34.0", + "bcrypt>=4.0,<6.0", ] [project.scripts] diff --git a/common/src/cartsnitch_common/seed/runner.py b/common/src/cartsnitch_common/seed/runner.py index c2b7784..d804b28 100644 --- a/common/src/cartsnitch_common/seed/runner.py +++ b/common/src/cartsnitch_common/seed/runner.py @@ -2,8 +2,10 @@ import random import time +import uuid from typing import Any +import bcrypt from faker import Faker from sqlalchemy import text from sqlalchemy.orm import Session @@ -184,6 +186,65 @@ def run_seed( session.commit() + _seed_uat_user(session) + elapsed = time.monotonic() - t0 _log("") _log(f"Seed complete in {elapsed:.1f}s") + + +# --------------------------------------------------------------------------- +# UAT seed user +# --------------------------------------------------------------------------- + +UAT_EMAIL = "uat@cartsnitch.com" +UAT_PASSWORD = "CartSnitch-UAT-2026!" +UAT_DISPLAY_NAME = "UAT Tester" +UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") + + +def _seed_uat_user(session: Session) -> None: + """Insert or verify the dedicated UAT test user. + + The user is created via Better-Auth's bcrypt hashing path so credentials + work against the live auth service. Idempotent — skips if the user already + exists. + """ + existing = session.execute( + text("SELECT id FROM users WHERE email = :email"), + {"email": UAT_EMAIL}, + ).fetchone() + + if existing is not None: + _log(f"UAT user {UAT_EMAIL} already exists — skipping") + return + + password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode() + + session.execute( + text( + "INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) " + "VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())" + ), + { + "id": str(UAT_USER_ID), + "email": UAT_EMAIL, + "hashed_password": password_hash, + "display_name": UAT_DISPLAY_NAME, + }, + ) + + session.execute( + text( + "INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) " + "VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())" + ), + { + "user_id": str(UAT_USER_ID), + "account_id": str(UAT_USER_ID), + "password": password_hash, + }, + ) + + session.commit() + _log(f"UAT user {UAT_EMAIL} created")