From 3cf87eb771d8ec1508320e4758d4d8f2b275a330 Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 04:40:50 +0000 Subject: [PATCH 01/16] fix(api): include alembic config and migrations in Docker image --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bb5d3bd..23166b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local COPY src/ ./src/ +COPY alembic.ini ./ +COPY alembic/ ./alembic/ USER 1000 EXPOSE 8000 @@ -23,4 +25,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" -CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"] From 49a9c60605177e34fba4b1274d182acd087e43cf Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 06:17:37 +0000 Subject: [PATCH 02/16] fix(api): add server_default to users.email_inbound_token Better-Auth creates users via raw SQL INSERT (not through SQLAlchemy), so it bypasses ORM defaults and causes HTTP 500 on sign-up/sign-in. Adds PostgreSQL server_default so INSERT without email_inbound_token auto-generates a URL-safe token matching Python secrets.token_urlsafe(16). Co-Authored-By: Paperclip --- .../006_email_inbound_token_server_default.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 alembic/versions/006_email_inbound_token_server_default.py diff --git a/alembic/versions/006_email_inbound_token_server_default.py b/alembic/versions/006_email_inbound_token_server_default.py new file mode 100644 index 0000000..ac1c678 --- /dev/null +++ b/alembic/versions/006_email_inbound_token_server_default.py @@ -0,0 +1,32 @@ +"""Add server_default to users.email_inbound_token. + +Revision ID: 006_email_inbound_token_server_default +Revises: 005_add_email_inbound_token +Create Date: 2026-04-04 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "006_email_inbound_token_server_default" +down_revision = "005_add_email_inbound_token" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "users", + "email_inbound_token", + server_default=sa.text( + "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" + ), + ) + + +def downgrade() -> None: + op.alter_column( + "users", + "email_inbound_token", + server_default=None, + ) From c5f43e8f4a1ad774b7765f84562369810015f205 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 06:31:48 +0000 Subject: [PATCH 03/16] fix(api): escape percent signs in alembic database URL for configparser CNPG-generated passwords containing URL-encoded chars (e.g. %2B, %2F) cause configparser.BasicInterpolation to fail with "invalid interpolation syntax". Escaping % as %% prevents this. Co-Authored-By: Paperclip --- alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alembic/env.py b/alembic/env.py index 3e563e1..694d4dc 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -18,7 +18,7 @@ if not db_url: "CARTSNITCH_DATABASE_URL_SYNC must be set. " "Example: postgresql://user:pass@localhost:5432/cartsnitch" ) -config.set_main_option("sqlalchemy.url", db_url) +config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%")) target_metadata = Base.metadata From f9984f7fb95ec957608bc1742f8029c65cb140e8 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 10:51:57 +0000 Subject: [PATCH 04/16] fix: install libpq5 runtime in API prod Docker stage psycopg2 compiled against libpq-dev in the build stage now has its runtime dependency (libpq5) available in the prod stage. Co-Authored-By: Paperclip --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 23166b8..fbb8633 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN pip install --no-cache-dir --prefix=/install . FROM python:3.12-slim AS prod +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* + WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local From 5c8fe9a62b8e7baed35d22257d6cf8396b0ad1c3 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:18:32 +0000 Subject: [PATCH 05/16] fix(api): make alembic migrations idempotent for fresh databases - 001: guard has_table check; skip if session_data already TEXT - 002: guard each ADD COLUMN / CREATE TABLE; guard password migration - 003: guard has_table; guard nullable check - 004: guard has_table; skip if users.id already TEXT - env.py: add Base.metadata.create_all after run_migrations to bootstrap fresh DBs - api/user.py: make hashed_password nullable; add email_verified, image, email_inbound_token fields Co-Authored-By: Paperclip --- Dockerfile | 4 +- alembic/env.py | 6 +- alembic/versions/001_encrypt_session_data.py | 16 +- alembic/versions/002_better_auth_tables.py | 145 ++++++++++-------- ...003_make_users_hashed_password_nullable.py | 21 ++- alembic/versions/004_fix_user_id_text.py | 16 +- src/cartsnitch_api/models/user.py | 12 +- 7 files changed, 147 insertions(+), 73 deletions(-) diff --git a/Dockerfile b/Dockerfile index 23166b8..e3b4bbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN pip install --no-cache-dir --prefix=/install . FROM python:3.12-slim AS prod +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* + WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local @@ -25,4 +27,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" -CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] diff --git a/alembic/env.py b/alembic/env.py index 3e563e1..5104dca 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -6,7 +6,7 @@ from logging.config import fileConfig from sqlalchemy import engine_from_config, pool from alembic import context -from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate +from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate config = context.config if config.config_file_name is not None: @@ -47,6 +47,10 @@ def run_migrations_online() -> None: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() + # Create any tables defined in models but not yet created by migrations. + # This bootstraps fresh databases that have no legacy schema. + # checkfirst=True ensures this is a no-op on existing databases. + Base.metadata.create_all(bind=connection, checkfirst=True) if context.is_offline_mode(): diff --git a/alembic/versions/001_encrypt_session_data.py b/alembic/versions/001_encrypt_session_data.py index 4932231..20c70ac 100644 --- a/alembic/versions/001_encrypt_session_data.py +++ b/alembic/versions/001_encrypt_session_data.py @@ -33,6 +33,21 @@ def _is_fernet_token(value: str) -> bool: def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — table created by Base.metadata.create_all with correct TEXT type + if not inspector.has_table("user_store_accounts"): + return + + # Already migrated? Skip if session_data is already TEXT (not JSON) + cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")} + if "session_data" not in cols: + return + col_type = str(cols["session_data"]["type"]).lower() + if "text" in col_type and "json" not in col_type: + return # already TEXT — nothing to do + # Change column type from JSON to TEXT to hold Fernet ciphertext op.alter_column( "user_store_accounts", @@ -43,7 +58,6 @@ def upgrade() -> None: postgresql_using="session_data::text", ) - conn = op.get_bind() rows = conn.execute( text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL") ).fetchall() diff --git a/alembic/versions/002_better_auth_tables.py b/alembic/versions/002_better_auth_tables.py index aa5dd93..169ed38 100644 --- a/alembic/versions/002_better_auth_tables.py +++ b/alembic/versions/002_better_auth_tables.py @@ -21,81 +21,96 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # --- 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)) + existing_user_cols = ( + [c["name"] for c in inspector.get_columns("users")] + if inspector.has_table("users") + else [] + ) + + if "email_verified" not in existing_user_cols: + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) + if "image" not in existing_user_cols: + 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"]) + if not inspector.has_table("sessions"): + 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"]) + if not inspector.has_table("accounts"): + 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"), - ) + if not inspector.has_table("verifications"): + 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() + # Only run on existing (non-fresh) DBs that already have users table with data + if inspector.has_table("users"): + 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}, - ) + 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") + op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id")) + op.execute(text("DROP TABLE IF EXISTS verifications")) + op.execute(text("DROP TABLE IF EXISTS accounts")) + op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id")) + op.execute(text("DROP INDEX IF EXISTS ix_sessions_token")) + op.execute(text("DROP TABLE IF EXISTS sessions")) + op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image")) + op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified")) diff --git a/alembic/versions/003_make_users_hashed_password_nullable.py b/alembic/versions/003_make_users_hashed_password_nullable.py index 8aec2bc..573b0ad 100644 --- a/alembic/versions/003_make_users_hashed_password_nullable.py +++ b/alembic/versions/003_make_users_hashed_password_nullable.py @@ -19,8 +19,25 @@ depends_on = None def upgrade() -> None: - op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — nothing to alter + if not inspector.has_table("users"): + return + + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "hashed_password" in cols and not cols["hashed_password"]["nullable"]: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) def downgrade() -> None: - op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) + conn = op.get_bind() + inspector = sa.inspect(conn) + + if not inspector.has_table("users"): + return + + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "hashed_password" in cols and cols["hashed_password"]["nullable"]: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) diff --git a/alembic/versions/004_fix_user_id_text.py b/alembic/versions/004_fix_user_id_text.py index a52bf9d..648333c 100644 --- a/alembic/versions/004_fix_user_id_text.py +++ b/alembic/versions/004_fix_user_id_text.py @@ -25,7 +25,21 @@ depends_on = None def upgrade() -> None: - # Step 1: Drop existing FK constraints + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — no tables yet, nothing to convert + if not inspector.has_table("users"): + return + + # Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB) + users_cols = {c["name"]: c for c in inspector.get_columns("users")} + if "id" in users_cols: + id_type = str(users_cols["id"]["type"]).lower() + if "text" in id_type and "uuid" not in id_type: + return # already TEXT — nothing to do + + # Step 1: Drop existing FK constraints (ignore if they don't exist) op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")) op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")) diff --git a/src/cartsnitch_api/models/user.py b/src/cartsnitch_api/models/user.py index 89390a3..9cbd4e8 100644 --- a/src/cartsnitch_api/models/user.py +++ b/src/cartsnitch_api/models/user.py @@ -4,7 +4,8 @@ import secrets from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint +import sqlalchemy as sa +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import AccountStatus @@ -23,13 +24,20 @@ class User(TimestampMixin, Base): id: Mapped[str] = mapped_column(Text, primary_key=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) + email_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false" + ) + image: Mapped[str | None] = mapped_column(Text, nullable=True) email_inbound_token: Mapped[str] = mapped_column( String(22), nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16), + server_default=sa.text( + "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" + ), ) # Relationships From c127fe2b2ec41f6d25f962ee7cfe4265867b42a6 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:39:27 +0000 Subject: [PATCH 06/16] fix(api): add fresh-DB guards to migrations 002, 005, and 006 - 002: wrap add_column calls in has_table("users") guard - 005: add has_table + column-existence guard before add_column - 006: add has_table + column + default-existence guard before alter_column Co-Authored-By: Paperclip --- alembic/versions/002_better_auth_tables.py | 18 ++++++++---------- .../versions/005_add_email_inbound_token.py | 14 +++++++++++--- .../006_email_inbound_token_server_default.py | 10 ++++++++++ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/alembic/versions/002_better_auth_tables.py b/alembic/versions/002_better_auth_tables.py index 169ed38..efa283f 100644 --- a/alembic/versions/002_better_auth_tables.py +++ b/alembic/versions/002_better_auth_tables.py @@ -25,16 +25,14 @@ def upgrade() -> None: inspector = sa.inspect(conn) # --- Extend users table for Better-Auth compatibility --- - existing_user_cols = ( - [c["name"] for c in inspector.get_columns("users")] - if inspector.has_table("users") - else [] - ) - - if "email_verified" not in existing_user_cols: - op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) - if "image" not in existing_user_cols: - op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) + # Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations) + # creates the users table with all columns, so migration 002 must not re-run add_column. + if inspector.has_table("users"): + existing_user_cols = [c["name"] for c in inspector.get_columns("users")] + if "email_verified" not in existing_user_cols: + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) + if "image" not in existing_user_cols: + op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) # --- Create sessions table --- if not inspector.has_table("sessions"): diff --git a/alembic/versions/005_add_email_inbound_token.py b/alembic/versions/005_add_email_inbound_token.py index 4fb7c2c..c5cc2a9 100644 --- a/alembic/versions/005_add_email_inbound_token.py +++ b/alembic/versions/005_add_email_inbound_token.py @@ -18,6 +18,15 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present + if not inspector.has_table("users"): + return + existing_cols = [c["name"] for c in inspector.get_columns("users")] + if "email_inbound_token" in existing_cols: + return + # Add column nullable first so existing rows can be backfilled op.add_column( "users", @@ -25,11 +34,10 @@ def upgrade() -> None: ) # Backfill existing users with unique tokens - connection = op.get_bind() - result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) + result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) for (user_id,) in result: token = secrets.token_urlsafe(16) - connection.execute( + conn.execute( sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"), {"token": token, "id": user_id}, ) diff --git a/alembic/versions/006_email_inbound_token_server_default.py b/alembic/versions/006_email_inbound_token_server_default.py index ac1c678..e090016 100644 --- a/alembic/versions/006_email_inbound_token_server_default.py +++ b/alembic/versions/006_email_inbound_token_server_default.py @@ -15,6 +15,16 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # Guard: on a fresh DB Base.metadata.create_all already sets the server_default + if not inspector.has_table("users"): + return + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "email_inbound_token" not in cols: + return + if cols["email_inbound_token"].get("default") is not None: + return op.alter_column( "users", "email_inbound_token", From 6e6e141d26476444c0441fd7b243daf5f7e1939e Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 17:10:29 +0000 Subject: [PATCH 07/16] fix(api): bootstrap users table in migration 007 + harden create_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create migration 007 to raw-SQL CREATE TABLE IF NOT EXISTS the users table as a safety net for fresh databases where Base.metadata.create_all() may fail due to import errors before the table is created. Wrap the create_all call in env.py with try/except so alembic never crashes due to create_all failures — migrations already handle table creation. Co-Authored-By: Paperclip --- alembic/env.py | 8 +++- alembic/versions/007_bootstrap_users_table.py | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/007_bootstrap_users_table.py diff --git a/alembic/env.py b/alembic/env.py index 504da75..b781b2f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -50,7 +50,13 @@ def run_migrations_online() -> None: # Create any tables defined in models but not yet created by migrations. # This bootstraps fresh databases that have no legacy schema. # checkfirst=True ensures this is a no-op on existing databases. - Base.metadata.create_all(bind=connection, checkfirst=True) + try: + Base.metadata.create_all(bind=connection, checkfirst=True) + except Exception as exc: + import logging + logging.getLogger("alembic.env").warning( + "create_all failed (non-fatal, migrations should handle table creation): %s", exc + ) if context.is_offline_mode(): diff --git a/alembic/versions/007_bootstrap_users_table.py b/alembic/versions/007_bootstrap_users_table.py new file mode 100644 index 0000000..e9695c0 --- /dev/null +++ b/alembic/versions/007_bootstrap_users_table.py @@ -0,0 +1,47 @@ +"""Bootstrap users table on fresh databases. + +On fresh databases, migrations 001-006 skip users-table operations because +the table does not exist yet. Base.metadata.create_all() in env.py is meant +to handle this, but if it fails (import errors, etc.) the table is never +created. This migration creates the users table with raw SQL as a safety net. + +Revision ID: 007_bootstrap_users_table +Revises: 006_email_inbound_token_server_default +Create Date: 2026-04-04 +""" + +import sqlalchemy as sa +from sqlalchemy import text + +from alembic import op + +revision = "007_bootstrap_users_table" +down_revision = "006_email_inbound_token_server_default" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if inspector.has_table("users"): + return # Table already exists (non-fresh DB or create_all already ran) + + conn.execute(text(""" + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + hashed_password VARCHAR(255), + display_name VARCHAR(100), + email_verified BOOLEAN NOT NULL DEFAULT false, + image TEXT, + email_inbound_token VARCHAR(22) NOT NULL UNIQUE + DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """)) + + +def downgrade() -> None: + op.execute(text("DROP TABLE IF EXISTS users")) From 8e359e0affa3359ccca1d58174aea4859c89c9d8 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 18:32:36 +0000 Subject: [PATCH 08/16] fix(api): widen alembic version_table column to 128 chars Default varchar(32) alembic_version column truncates long revision IDs like 003_make_users_hashed_password_nullable (39 chars) on fresh databases. Set version_table_column_width=128 in both context.configure() calls. Co-Authored-By: Paperclip --- alembic/env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/alembic/env.py b/alembic/env.py index b781b2f..38d7156 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -31,6 +31,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + version_table_column_width=128, ) with context.begin_transaction(): context.run_migrations() @@ -44,7 +45,7 @@ def run_migrations_online() -> None: poolclass=pool.NullPool, ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128) with context.begin_transaction(): context.run_migrations() # Create any tables defined in models but not yet created by migrations. From a54a57fbc4e0e9de0f1de26c29241c88b76ef739 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 18:40:22 +0000 Subject: [PATCH 09/16] fix(api): read __Secure- prefixed session cookie in auth Better-auth sets the session cookie with the __Secure- prefix on HTTPS deployments. The API was only reading the plain cookie name, causing all authenticated calls to return 401 in dev/UAT/prod environments. Check __Secure-better-auth.session_token first, fall back to better-auth.session_token for HTTP local dev compatibility. Co-Authored-By: Paperclip --- src/cartsnitch_api/auth/dependencies.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index 6fe1db4..91c438f 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/src/cartsnitch_api/auth/dependencies.py @@ -19,6 +19,8 @@ bearer_scheme = HTTPBearer(auto_error=False) # Better-Auth session cookie name SESSION_COOKIE_NAME = "better-auth.session_token" +# Secure prefix used by better-auth on HTTPS deployments +SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token" async def _validate_session_token(token: str, db: AsyncSession) -> str: @@ -65,8 +67,8 @@ async def get_current_user( """ token: str | None = None - # 1. Check session cookie - cookie_token = request.cookies.get(SESSION_COOKIE_NAME) + # 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev) + cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME) if cookie_token: token = cookie_token From 89293d1811aee2c865fc7946bb1b28932a6fe740 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:00:09 +0000 Subject: [PATCH 10/16] fix(api): hash session token with SHA-256 before DB lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better-Auth v1.2+ stores SHA-256(raw_token) in the sessions.token column. The cookie/Bearer header carries the raw token, so the API was doing a plain-text lookup that would never match a hashed value — causing all authenticated endpoints to return 401. - Add hashlib import and hash token in _validate_session_token() - Update conftest._create_test_user_and_session() to store hashed tokens - Update test_expired_session_rejected() to store hashed tokens Co-Authored-By: Paperclip --- src/cartsnitch_api/auth/dependencies.py | 8 ++++++-- tests/conftest.py | 7 +++++-- tests/test_auth/test_auth_endpoints.py | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index 91c438f..4eac005 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/src/cartsnitch_api/auth/dependencies.py @@ -4,6 +4,7 @@ Validates Better-Auth session tokens from cookies or Bearer header. Sessions are verified by querying the shared sessions table directly. """ +import hashlib from datetime import UTC, datetime from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -26,11 +27,14 @@ SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token" async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. - Returns the user_id (as str) if the session is valid and not expired. + Better-Auth v1.2+ stores SHA-256(raw_token) in the DB. + The cookie/Bearer header carries the raw token, so we hash before lookup. """ + token_hash = hashlib.sha256(token.encode()).hexdigest() + result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": token}, + {"token": token_hash}, ) row = result.first() diff --git a/tests/conftest.py b/tests/conftest.py index 61810e1..647dbd9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ Session-based auth: tests create users and sessions directly in the DB, matching the Better-Auth session validation flow. """ +import hashlib import secrets import uuid from datetime import UTC, datetime, timedelta @@ -136,12 +137,14 @@ async def client(db_engine): async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]: """Create a test user and a valid session directly in the DB. - Returns (user_dict, session_token). + Returns (user_dict, session_token). Better-Auth v1.2+ stores SHA-256 + hashed tokens in the DB, so the token is hashed before insertion. """ user_id = str(uuid.uuid4()) email = user_overrides.get("email", "test@example.com") display_name = user_overrides.get("display_name", "Test User") session_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(session_token.encode()).hexdigest() session_id = str(uuid.uuid4()) now = datetime.now(UTC).isoformat() expires = (datetime.now(UTC) + timedelta(days=7)).isoformat() @@ -169,7 +172,7 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o ), { "id": session_id, - "token": session_token, + "token": token_hash, "user_id": user_id, "expires_at": expires, "created_at": now, diff --git a/tests/test_auth/test_auth_endpoints.py b/tests/test_auth/test_auth_endpoints.py index 7b096ae..83e49d7 100644 --- a/tests/test_auth/test_auth_endpoints.py +++ b/tests/test_auth/test_auth_endpoints.py @@ -74,6 +74,7 @@ async def test_delete_me(client, auth_headers): @pytest.mark.asyncio async def test_expired_session_rejected(client, db_engine): """Expired sessions must be rejected.""" + import hashlib import secrets import uuid from datetime import UTC, datetime, timedelta @@ -82,6 +83,7 @@ async def test_expired_session_rejected(client, db_engine): user_id = str(uuid.uuid4()) session_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(session_token.encode()).hexdigest() now = datetime.now(UTC).isoformat() expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat() @@ -108,7 +110,7 @@ async def test_expired_session_rejected(client, db_engine): ), { "id": str(uuid.uuid4()), - "token": session_token, + "token": token_hash, "uid": user_id, "ea": expired, "ca": now, From 30e4c69ff6b39dcfb4839cad429146a3d99ba05f Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:21:26 +0000 Subject: [PATCH 11/16] =?UTF-8?q?fix(api):=20revert=20SHA-256=20session=20?= =?UTF-8?q?token=20hashing=20=E2=80=94=20better-auth=20stores=20raw=20toke?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better-auth v1.5.6 stores raw 32-char tokens in sessions.token, not SHA-256 hashes. The SHA-256 fix from PR #136 causes all authenticated API calls to return 401 because the UAT sessions table contains raw tokens. - Remove hashlib from dependencies.py; compare tokens directly - Remove hashlib from conftest.py; store raw tokens in test DB - Remove hashlib from test_expired_session_rejected; use raw tokens Co-Authored-By: Claude Sonnet 4.6 --- src/cartsnitch_api/auth/dependencies.py | 9 +++------ tests/conftest.py | 8 +++----- tests/test_auth/test_auth_endpoints.py | 4 +--- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index 4eac005..390b40e 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/src/cartsnitch_api/auth/dependencies.py @@ -4,7 +4,6 @@ Validates Better-Auth session tokens from cookies or Bearer header. Sessions are verified by querying the shared sessions table directly. """ -import hashlib from datetime import UTC, datetime from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -27,14 +26,12 @@ SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token" async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. - Better-Auth v1.2+ stores SHA-256(raw_token) in the DB. - The cookie/Bearer header carries the raw token, so we hash before lookup. + Better-Auth stores the raw token in the DB. The cookie/Bearer header + carries the same raw token, so we compare directly. """ - token_hash = hashlib.sha256(token.encode()).hexdigest() - result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": token_hash}, + {"token": token}, ) row = result.first() diff --git a/tests/conftest.py b/tests/conftest.py index 647dbd9..bb84c20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ Session-based auth: tests create users and sessions directly in the DB, matching the Better-Auth session validation flow. """ -import hashlib import secrets import uuid from datetime import UTC, datetime, timedelta @@ -137,14 +136,13 @@ async def client(db_engine): async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]: """Create a test user and a valid session directly in the DB. - Returns (user_dict, session_token). Better-Auth v1.2+ stores SHA-256 - hashed tokens in the DB, so the token is hashed before insertion. + Returns (user_dict, session_token). Better-Auth stores the raw token + in the DB, so we insert it as-is. """ user_id = str(uuid.uuid4()) email = user_overrides.get("email", "test@example.com") display_name = user_overrides.get("display_name", "Test User") session_token = secrets.token_urlsafe(32) - token_hash = hashlib.sha256(session_token.encode()).hexdigest() session_id = str(uuid.uuid4()) now = datetime.now(UTC).isoformat() expires = (datetime.now(UTC) + timedelta(days=7)).isoformat() @@ -172,7 +170,7 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o ), { "id": session_id, - "token": token_hash, + "token": session_token, "user_id": user_id, "expires_at": expires, "created_at": now, diff --git a/tests/test_auth/test_auth_endpoints.py b/tests/test_auth/test_auth_endpoints.py index 83e49d7..7b096ae 100644 --- a/tests/test_auth/test_auth_endpoints.py +++ b/tests/test_auth/test_auth_endpoints.py @@ -74,7 +74,6 @@ async def test_delete_me(client, auth_headers): @pytest.mark.asyncio async def test_expired_session_rejected(client, db_engine): """Expired sessions must be rejected.""" - import hashlib import secrets import uuid from datetime import UTC, datetime, timedelta @@ -83,7 +82,6 @@ async def test_expired_session_rejected(client, db_engine): user_id = str(uuid.uuid4()) session_token = secrets.token_urlsafe(32) - token_hash = hashlib.sha256(session_token.encode()).hexdigest() now = datetime.now(UTC).isoformat() expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat() @@ -110,7 +108,7 @@ async def test_expired_session_rejected(client, db_engine): ), { "id": str(uuid.uuid4()), - "token": token_hash, + "token": session_token, "uid": user_id, "ea": expired, "ca": now, From a73c26ab63b3947673b6a4a5a2a4ab0b49359ced Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:52:24 +0000 Subject: [PATCH 12/16] fix(api): accept DATABASE_URL as fallback for shared DB with auth service API config.py now reads CARTSNITCH_DATABASE_URL first, falls back to DATABASE_URL (which the infra K8s overlay sets for all pods), and finally falls back to the hardcoded default. Also normalizes plain postgresql:// to postgresql+asyncpg:// for the asyncpg driver. Fixes CAR-510. Co-Authored-By: Paperclip --- src/cartsnitch_api/config.py | 14 +++++++++-- tests/test_config.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/test_config.py diff --git a/src/cartsnitch_api/config.py b/src/cartsnitch_api/config.py index 5111997..7642deb 100644 --- a/src/cartsnitch_api/config.py +++ b/src/cartsnitch_api/config.py @@ -1,13 +1,16 @@ import base64 -from pydantic import model_validator +from pydantic import AliasChoices, Field, model_validator from pydantic_settings import BaseSettings class Settings(BaseSettings): model_config = {"env_prefix": "CARTSNITCH_"} - database_url: str = "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch" + database_url: str = Field( + default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch", + validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"), + ) redis_url: str = "redis://localhost:6379/0" jwt_secret_key: str = "change-me-in-production" @@ -49,5 +52,12 @@ class Settings(BaseSettings): ) from None return self + @model_validator(mode="after") + def normalize_database_url(self): + """Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver.""" + if self.database_url.startswith("postgresql://"): + self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + return self + settings = Settings() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f594bc2 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,48 @@ +"""Tests for Settings config, specifically the database_url env var fallback.""" + +import os + +from cartsnitch_api.config import Settings + + +def test_database_url_prefers_cartsnitch_prefix(): + """CARTSNITCH_DATABASE_URL takes precedence over DATABASE_URL.""" + env = { + "CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://user1:pass1@host1:5432/db1", + "DATABASE_URL": "postgresql://user2:pass2@host2:5432/db2", + } + settings = Settings(**env) + assert settings.database_url == "postgresql+asyncpg://user1:pass1@host1:5432/db1" + + +def test_database_url_falls_back_to_database_url(): + """When CARTSNITCH_DATABASE_URL is absent, DATABASE_URL is accepted.""" + env = { + "DATABASE_URL": "postgresql://user:pass@dbhost:5432/mydb", + } + settings = Settings(**env) + assert settings.database_url == "postgresql+asyncpg://user:pass@dbhost:5432/mydb" + + +def test_database_url_normalizes_plain_postgresql_prefix(): + """DATABASE_URL with plain postgresql:// is normalized to postgresql+asyncpg://.""" + env = { + "DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch", + } + settings = Settings(**env) + assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch" + + +def test_database_url_preserves_asyncpg_prefix(): + """CARTSNITCH_DATABASE_URL with postgresql+asyncpg:// is left unchanged.""" + env = { + "CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch", + } + settings = Settings(**env) + assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch" + + +def test_database_url_default(): + """When neither env var is set, the hardcoded default is used.""" + settings = Settings() + assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch" From b3af0833e80b91ebb3770b847f0634dbd3a42a54 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 20:32:43 +0000 Subject: [PATCH 13/16] fix(auth): parse compound Better-Auth cookie/bearer token to extract token part Better-Auth sets the session cookie as "token.sessionId". The DB stores only the token part, so passing the full compound value caused 401s. Splits on "." for both cookie and Bearer paths. Tests added for compound cookie, raw token cookie (regression), and compound Bearer token. Co-Authored-By: Paperclip --- src/cartsnitch_api/auth/dependencies.py | 7 +++- tests/test_auth/test_auth_endpoints.py | 50 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index 390b40e..5040741 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/src/cartsnitch_api/auth/dependencies.py @@ -71,11 +71,14 @@ async def get_current_user( # 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev) cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME) if cookie_token: - token = cookie_token + # Better-Auth cookie format is "token.sessionId" — extract just the token part + token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token # 2. Fall back to Bearer header if not token and credentials: - token = credentials.credentials + # Callers might pass the compound value here too + raw = credentials.credentials + token = raw.split(".")[0] if "." in raw else raw if not token: raise HTTPException( diff --git a/tests/test_auth/test_auth_endpoints.py b/tests/test_auth/test_auth_endpoints.py index 7b096ae..9b55a4c 100644 --- a/tests/test_auth/test_auth_endpoints.py +++ b/tests/test_auth/test_auth_endpoints.py @@ -71,6 +71,56 @@ async def test_delete_me(client, auth_headers): assert resp.status_code == 404 +@pytest.mark.asyncio +async def test_get_me_compound_cookie(client, db_engine): + """Compound cookie value (token.sessionId) must be parsed to extract the token part.""" + from tests.conftest import _create_test_user_and_session + + _, session_token = await _create_test_user_and_session( + client, db_engine, email="compound@example.com", display_name="Compound User" + ) + compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7" + resp = await client.get( + "/auth/me", + headers={"Cookie": f"better-auth.session_token={compound}"}, + ) + assert resp.status_code == 200 + assert resp.json()["email"] == "compound@example.com" + + +@pytest.mark.asyncio +async def test_get_me_raw_token_cookie(client, db_engine): + """Raw token (no dot) in cookie must still work — regression guard.""" + from tests.conftest import _create_test_user_and_session + + _, session_token = await _create_test_user_and_session( + client, db_engine, email="rawcookie@example.com", display_name="Raw Cookie User" + ) + resp = await client.get( + "/auth/me", + headers={"Cookie": f"better-auth.session_token={session_token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["email"] == "rawcookie@example.com" + + +@pytest.mark.asyncio +async def test_get_me_compound_bearer(client, db_engine): + """Compound Bearer token (token.sessionId) must be parsed to extract the token part.""" + from tests.conftest import _create_test_user_and_session + + _, session_token = await _create_test_user_and_session( + client, db_engine, email="compoundbearer@example.com", display_name="Compound Bearer User" + ) + compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7" + resp = await client.get( + "/auth/me", + headers={"Authorization": f"Bearer {compound}"}, + ) + assert resp.status_code == 200 + assert resp.json()["email"] == "compoundbearer@example.com" + + @pytest.mark.asyncio async def test_expired_session_rejected(client, db_engine): """Expired sessions must be rejected.""" From 09c79371022a1d8c9f8accd884f31f3911f84961 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 21:12:13 +0000 Subject: [PATCH 14/16] fix(api): import Base from models package to register all ORM tables The models/__init__.py imports all ORM model classes (Store, Product, Coupon, etc.) which registers their table definitions with Base.metadata. Importing Base directly from models.base skips this registration, so alembic's create_all() on fresh databases fails to create app tables. Co-Authored-By: Paperclip --- alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alembic/env.py b/alembic/env.py index 38d7156..d692a7f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -6,7 +6,7 @@ from logging.config import fileConfig from sqlalchemy import engine_from_config, pool from alembic import context -from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate +from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate config = context.config if config.config_file_name is not None: From f058ac06866b352d8b02288fb4c04a221ef39bae Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:22:24 +0000 Subject: [PATCH 15/16] fix(api): create domain tables migration + fix create_all commit Co-Authored-By: Paperclip --- alembic/env.py | 1 + alembic/versions/008_create_domain_tables.py | 210 +++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 alembic/versions/008_create_domain_tables.py diff --git a/alembic/env.py b/alembic/env.py index d692a7f..6844fba 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -53,6 +53,7 @@ def run_migrations_online() -> None: # checkfirst=True ensures this is a no-op on existing databases. try: Base.metadata.create_all(bind=connection, checkfirst=True) + connection.commit() except Exception as exc: import logging logging.getLogger("alembic.env").warning( diff --git a/alembic/versions/008_create_domain_tables.py b/alembic/versions/008_create_domain_tables.py new file mode 100644 index 0000000..021c5bf --- /dev/null +++ b/alembic/versions/008_create_domain_tables.py @@ -0,0 +1,210 @@ +"""Create domain tables (stores, purchases, coupons, etc.). + +Revision ID: 008_create_domain_tables +Revises: 007_bootstrap_users_table +Create Date: 2026-04-04 +""" + +import sqlalchemy as sa +from sqlalchemy import text + +from alembic import op + +revision = "008_create_domain_tables" +down_revision = "007_bootstrap_users_table" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + # 1. stores + if not inspector.has_table("stores"): + op.create_table( + "stores", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("slug", sa.String(20), nullable=False, unique=True), + sa.Column("logo_url", sa.String(500), nullable=True), + sa.Column("website_url", sa.String(500), 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), + ) + + # 2. store_locations + if not inspector.has_table("store_locations"): + op.create_table( + "store_locations", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("address", sa.String(300), nullable=False), + sa.Column("city", sa.String(100), nullable=False), + sa.Column("state", sa.String(2), nullable=False), + sa.Column("zip", sa.String(10), nullable=False), + sa.Column("lat", sa.Float(), nullable=True), + sa.Column("lng", sa.Float(), 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), + ) + + # 3. normalized_products + if not inspector.has_table("normalized_products"): + op.create_table( + "normalized_products", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("canonical_name", sa.String(300), nullable=False), + sa.Column("category", sa.String(50), nullable=True), + sa.Column("subcategory", sa.String(100), nullable=True), + sa.Column("brand", sa.String(200), nullable=True), + sa.Column("size", sa.String(50), nullable=True), + sa.Column("size_unit", sa.String(10), nullable=True), + sa.Column("upc_variants", sa.JSON(), 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), + ) + + # 4. purchases + if not inspector.has_table("purchases"): + op.create_table( + "purchases", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True), + sa.Column("receipt_id", sa.String(200), nullable=False), + sa.Column("purchase_date", sa.Date(), nullable=False), + sa.Column("total", sa.Numeric(10, 2), nullable=False), + sa.Column("subtotal", sa.Numeric(10, 2), nullable=True), + sa.Column("tax", sa.Numeric(10, 2), nullable=True), + sa.Column("savings_total", sa.Numeric(10, 2), nullable=True), + sa.Column("source_url", sa.String(500), nullable=True), + sa.Column("raw_data", sa.JSON(), nullable=True), + sa.Column("ingested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), 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.UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"), + sa.Index("ix_purchases_user_store", "user_id", "store_id"), + ) + + # 5. purchase_items + if not inspector.has_table("purchase_items"): + op.create_table( + "purchase_items", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("purchase_id", sa.Uuid(), sa.ForeignKey("purchases.id"), nullable=False), + sa.Column("product_name_raw", sa.String(300), nullable=False), + sa.Column("upc", sa.String(20), nullable=True), + sa.Column("quantity", sa.Numeric(10, 3), nullable=False), + sa.Column("unit_price", sa.Numeric(10, 2), nullable=False), + sa.Column("extended_price", sa.Numeric(10, 2), nullable=False), + sa.Column("regular_price", sa.Numeric(10, 2), nullable=True), + sa.Column("sale_price", sa.Numeric(10, 2), nullable=True), + sa.Column("coupon_discount", sa.Numeric(10, 2), nullable=True), + sa.Column("loyalty_discount", sa.Numeric(10, 2), nullable=True), + sa.Column("category_raw", sa.String(100), nullable=True), + sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), 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), + ) + + # 6. coupons + if not inspector.has_table("coupons"): + op.create_table( + "coupons", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True), + sa.Column("title", sa.String(300), nullable=False), + sa.Column("description", sa.String(1000), nullable=True), + sa.Column("discount_type", sa.String(20), nullable=False), + sa.Column("discount_value", sa.Numeric(10, 2), nullable=True), + sa.Column("min_purchase", sa.Numeric(10, 2), nullable=True), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_to", sa.Date(), nullable=True), + sa.Column("requires_clip", sa.Boolean(), server_default=text("false"), nullable=False), + sa.Column("coupon_code", sa.String(100), nullable=True), + sa.Column("source_url", sa.String(500), nullable=True), + sa.Column("scraped_at", sa.DateTime(timezone=True), 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), + ) + + # 7. price_history + if not inspector.has_table("price_history"): + op.create_table( + "price_history", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False), + sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("observed_date", sa.Date(), nullable=False), + sa.Column("regular_price", sa.Numeric(10, 2), nullable=False), + sa.Column("sale_price", sa.Numeric(10, 2), nullable=True), + sa.Column("loyalty_price", sa.Numeric(10, 2), nullable=True), + sa.Column("coupon_price", sa.Numeric(10, 2), nullable=True), + sa.Column("source", sa.String(20), nullable=False), + sa.Column("purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), 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.Index("ix_price_history_product_store_date", "normalized_product_id", "store_id", "observed_date"), + ) + + # 8. shrinkflation_events + if not inspector.has_table("shrinkflation_events"): + op.create_table( + "shrinkflation_events", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False), + sa.Column("detected_date", sa.Date(), nullable=False), + sa.Column("old_size", sa.String(50), nullable=False), + sa.Column("new_size", sa.String(50), nullable=False), + sa.Column("old_unit", sa.String(10), nullable=True), + sa.Column("new_unit", sa.String(10), nullable=True), + sa.Column("price_at_old_size", sa.Numeric(10, 2), nullable=True), + sa.Column("price_at_new_size", sa.Numeric(10, 2), nullable=True), + sa.Column("confidence", sa.Numeric(3, 2), server_default=text("1.00"), nullable=False), + sa.Column("notes", sa.String(1000), 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), + ) + + # 9. user_store_accounts + if not inspector.has_table("user_store_accounts"): + op.create_table( + "user_store_accounts", + sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True), + sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("session_data", sa.JSON(), nullable=True), + sa.Column("session_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(20), server_default=text("'active'"), 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.UniqueConstraint("user_id", "store_id", name="uq_user_store_account"), + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + if inspector.has_table("user_store_accounts"): + op.drop_table("user_store_accounts") + if inspector.has_table("shrinkflation_events"): + op.drop_table("shrinkflation_events") + if inspector.has_table("price_history"): + op.drop_table("price_history") + if inspector.has_table("coupons"): + op.drop_table("coupons") + if inspector.has_table("purchase_items"): + op.drop_table("purchase_items") + if inspector.has_table("purchases"): + op.drop_table("purchases") + if inspector.has_table("normalized_products"): + op.drop_table("normalized_products") + if inspector.has_table("store_locations"): + op.drop_table("store_locations") + if inspector.has_table("stores"): + op.drop_table("stores") From 0d4420f083d6e71ef1a6e0d109fff3cfcecf0a2f Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 21:36:05 +0000 Subject: [PATCH 16/16] fix(api): commit after create_all in alembic env.py SQLAlchemy 2.0 removed implicit autocommit; without an explicit connection.commit() DDL changes from create_all() are rolled back when the connection closes, leaving fresh databases without tables. Co-Authored-By: Paperclip --- alembic/env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/alembic/env.py b/alembic/env.py index d692a7f..6844fba 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -53,6 +53,7 @@ def run_migrations_online() -> None: # checkfirst=True ensures this is a no-op on existing databases. try: Base.metadata.create_all(bind=connection, checkfirst=True) + connection.commit() except Exception as exc: import logging logging.getLogger("alembic.env").warning(