From 73c038e40622f45c055a7ca4ee8f893ad9596389 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Fri, 3 Apr 2026 20:12:35 +0000 Subject: [PATCH 01/20] feat(common): sync email_inbound_token from standalone repo --- .../versions/001_add_email_inbound_token.py | 37 +++++++++++++++++++ common/src/cartsnitch_common/models/user.py | 4 ++ common/src/cartsnitch_common/schemas/user.py | 1 + common/tests/test_models.py | 34 +++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 common/alembic/versions/001_add_email_inbound_token.py diff --git a/common/alembic/versions/001_add_email_inbound_token.py b/common/alembic/versions/001_add_email_inbound_token.py new file mode 100644 index 0000000..43a6fe8 --- /dev/null +++ b/common/alembic/versions/001_add_email_inbound_token.py @@ -0,0 +1,37 @@ +"""Add email_inbound_token to users. + +Revision ID: 001_add_email_inbound_token +Revises: +Create Date: 2026-04-02 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "001_add_email_inbound_token" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("email_inbound_token", sa.String(22), nullable=True)) + op.create_unique_constraint("uq_users_email_inbound_token", "users", ["email_inbound_token"]) + + # Backfill existing users with generated tokens (PostgreSQL) + op.execute( + "UPDATE users SET email_inbound_token = " + "substring(replace(gen_random_uuid()::text, '-', ''), 1, 22) " + "WHERE email_inbound_token IS NULL" + ) + + # Alter to non-nullable + op.alter_column("users", "email_inbound_token", nullable=False) + + +def downgrade() -> None: + op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique") + op.drop_column("users", "email_inbound_token") \ No newline at end of file diff --git a/common/src/cartsnitch_common/models/user.py b/common/src/cartsnitch_common/models/user.py index 4382a08..68e631b 100644 --- a/common/src/cartsnitch_common/models/user.py +++ b/common/src/cartsnitch_common/models/user.py @@ -1,5 +1,6 @@ """User and UserStoreAccount models.""" +import secrets import uuid from datetime import datetime from typing import TYPE_CHECKING @@ -21,6 +22,9 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "users" email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + email_inbound_token: Mapped[str] = mapped_column( + String(22), nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16) + ) 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") diff --git a/common/src/cartsnitch_common/schemas/user.py b/common/src/cartsnitch_common/schemas/user.py index 2c174ba..4f91d49 100644 --- a/common/src/cartsnitch_common/schemas/user.py +++ b/common/src/cartsnitch_common/schemas/user.py @@ -20,6 +20,7 @@ class UserRead(BaseModel): id: uuid.UUID email: str display_name: str | None + email_inbound_token: str created_at: datetime updated_at: datetime diff --git a/common/tests/test_models.py b/common/tests/test_models.py index 8b5eb68..14cffeb 100644 --- a/common/tests/test_models.py +++ b/common/tests/test_models.py @@ -147,6 +147,40 @@ class TestStoreLocationModel: assert loc.lat == pytest.approx(42.2808) +class TestUserModel: + def test_email_inbound_token_auto_populated(self, session): + user = User( + id=uuid.uuid4(), + email="token_test@example.com", + hashed_password="hashed", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(user) + session.commit() + assert user.email_inbound_token is not None + assert len(user.email_inbound_token) == 22 + + def test_email_inbound_token_unique(self, session): + user1 = User( + id=uuid.uuid4(), + email="user1@example.com", + hashed_password="hashed", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + user2 = User( + id=uuid.uuid4(), + email="user2@example.com", + hashed_password="hashed", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add_all([user1, user2]) + session.commit() + assert user1.email_inbound_token != user2.email_inbound_token + + class TestUserStoreAccountModel: def test_account_status_enum(self, session): user = User( From bb7010f88164dcdc22929654ee43c969d87e30cf Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 04:18:03 +0000 Subject: [PATCH 02/20] fix(auth): add UAT hostname to trustedOrigins Co-Authored-By: Paperclip --- auth/src/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/auth/src/auth.ts b/auth/src/auth.ts index eae43b8..b1e0e1b 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -95,5 +95,6 @@ export const auth = betterAuth({ "https://cartsnitch.com", "https://cartsnitch.farh.net", "https://cartsnitch.dev.farh.net", + "https://cartsnitch.uat.farh.net", ], }); From 6e0cb93ee2329ac91a48f151ec1f646ad412ba69 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 03/20] fix(api): include alembic config and migrations in Docker image --- api/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/Dockerfile b/api/Dockerfile index bb5d3bd..23166b8 100644 --- a/api/Dockerfile +++ b/api/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 a663729121cc057ceaed5c5e67e6578d0944b6b0 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:54:09 +0000 Subject: [PATCH 04/20] fix(ci): build and deploy from dev and uat branches --- .github/workflows/ci.yml | 132 ++++++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af98dd..1726986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev, uat] pull_request: - branches: [main] + branches: [main, dev, uat] concurrency: group: ci-${{ github.ref }} @@ -99,10 +99,11 @@ jobs: build-and-push: runs-on: runners-cartsnitch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [lint, test, e2e] outputs: calver_tag: ${{ steps.calver.outputs.version }} + sha_tag: sha-${{ github.sha }} steps: - uses: actions/checkout@v4 with: @@ -126,14 +127,14 @@ jobs: echo "CalVer tag: $VERSION" - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -154,7 +155,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} target: prod @@ -169,10 +170,11 @@ jobs: build-and-push-auth: runs-on: runners-cartsnitch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [lint, test, e2e] outputs: calver_tag: ${{ steps.calver.outputs.version }} + sha_tag: sha-${{ github.sha }} steps: - uses: actions/checkout@v4 with: @@ -195,14 +197,14 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -224,16 +226,17 @@ jobs: with: context: ./auth file: ./auth/Dockerfile - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-and-push-receiptwitness: runs-on: runners-cartsnitch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [lint, test] outputs: calver_tag: ${{ steps.calver.outputs.version }} + sha_tag: sha-${{ github.sha }} steps: - uses: actions/checkout@v4 with: @@ -251,14 +254,14 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -280,16 +283,17 @@ jobs: with: context: . file: ./receiptwitness/Dockerfile - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-and-push-api: runs-on: runners-cartsnitch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [lint, test] outputs: calver_tag: ${{ steps.calver.outputs.version }} + sha_tag: sha-${{ github.sha }} steps: - uses: actions/checkout@v4 with: @@ -307,14 +311,14 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -336,14 +340,14 @@ jobs: with: context: ./api file: ./api/Dockerfile - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} deploy-dev: runs-on: runners-cartsnitch needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] - if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') steps: - name: Generate GitHub App token id: app-token @@ -368,29 +372,65 @@ jobs: - name: Install kustomize uses: imranismail/setup-kustomize@v2 + - name: Determine image tag for frontend + id: frontend_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi + - name: Update frontend image tag if: needs.build-and-push.result == 'success' run: | cd infra/apps/overlays/dev - kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }} + + - name: Determine image tag for auth + id: auth_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update auth image tag if: needs.build-and-push-auth.result == 'success' run: | cd infra/apps/overlays/dev - kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }} + + - name: Determine image tag for receiptwitness + id: receiptwitness_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update receiptwitness image tag if: needs.build-and-push-receiptwitness.result == 'success' run: | cd infra/apps/overlays/dev - kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }} + + - name: Determine image tag for api + id: api_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update api image tag if: needs.build-and-push-api.result == 'success' run: | cd infra/apps/overlays/dev - kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }} - name: Commit and push to infra run: | @@ -405,7 +445,7 @@ jobs: deploy-uat: runs-on: runners-cartsnitch needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] - if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main') steps: - name: Generate GitHub App token id: app-token @@ -430,29 +470,65 @@ jobs: - name: Install kustomize uses: imranismail/setup-kustomize@v2 + - name: Determine image tag for frontend + id: frontend_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi + - name: Update frontend image tag if: needs.build-and-push.result == 'success' run: | cd infra/apps/overlays/uat - kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }} + + - name: Determine image tag for auth + id: auth_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update auth image tag if: needs.build-and-push-auth.result == 'success' run: | cd infra/apps/overlays/uat - kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }} + + - name: Determine image tag for receiptwitness + id: receiptwitness_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update receiptwitness image tag if: needs.build-and-push-receiptwitness.result == 'success' run: | cd infra/apps/overlays/uat - kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }} + + - name: Determine image tag for api + id: api_tag + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT" + fi - name: Update api image tag if: needs.build-and-push-api.result == 'success' run: | cd infra/apps/overlays/uat - kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }} - name: Commit and push to infra run: | From 7639be9a4120c5477deee8b06f7edb3152524d31 Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" Date: Sat, 4 Apr 2026 05:31:04 +0000 Subject: [PATCH 05/20] fix(ci): use full SHA in docker/metadata-action tags The sha_tag output is a 40-char SHA, but docker/metadata-action defaults to short (7-char) SHA tags. This caused UAT pods to fail image pulls because kustomization tags didn't match GHCR tags. Change type=sha,prefix=sha- to type=sha,prefix=sha-,format=long in all four build jobs (cartsnitch, auth, receiptwitness, api). Fixes CAR-482. Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1726986..24a3251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=sha,prefix=sha- + type=sha,prefix=sha-,format=long type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} @@ -217,7 +217,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }} tags: | - type=sha,prefix=sha- + type=sha,prefix=sha-,format=long type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} @@ -274,7 +274,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }} tags: | - type=sha,prefix=sha- + type=sha,prefix=sha-,format=long type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} @@ -331,7 +331,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }} tags: | - type=sha,prefix=sha- + type=sha,prefix=sha-,format=long type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} From cf164157200e638d2c6b8dcc57548e0e579883b2 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 06:17:37 +0000 Subject: [PATCH 06/20] 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 +++++++++++++++++++ common/src/cartsnitch_common/models/user.py | 10 ++++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 api/alembic/versions/006_email_inbound_token_server_default.py diff --git a/api/alembic/versions/006_email_inbound_token_server_default.py b/api/alembic/versions/006_email_inbound_token_server_default.py new file mode 100644 index 0000000..ac1c678 --- /dev/null +++ b/api/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, + ) diff --git a/common/src/cartsnitch_common/models/user.py b/common/src/cartsnitch_common/models/user.py index 68e631b..991123b 100644 --- a/common/src/cartsnitch_common/models/user.py +++ b/common/src/cartsnitch_common/models/user.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_common.constants import AccountStatus @@ -23,7 +23,13 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email_inbound_token: Mapped[str] = mapped_column( - String(22), nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16) + String(22), + nullable=False, + unique=True, + default=lambda: secrets.token_urlsafe(16), + server_default=text( + "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" + ), ) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) From 4996ff7432dc7c5e3bb3e67d0584e6b0a919cbfd Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 06:31:48 +0000 Subject: [PATCH 07/20] 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 --- api/alembic/env.py | 2 +- common/alembic/env.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/alembic/env.py b/api/alembic/env.py index 3e563e1..694d4dc 100644 --- a/api/alembic/env.py +++ b/api/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 diff --git a/common/alembic/env.py b/common/alembic/env.py index cd893e0..7cbf061 100644 --- a/common/alembic/env.py +++ b/common/alembic/env.py @@ -14,7 +14,7 @@ if config.config_file_name is not None: db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC") if db_url: - config.set_main_option("sqlalchemy.url", db_url) + config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%")) target_metadata = Base.metadata From a25b673dd6c651612f554ff5d0d104c781cd9f06 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 10:51:57 +0000 Subject: [PATCH 08/20] 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 --- api/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/Dockerfile b/api/Dockerfile index 23166b8..fbb8633 100644 --- a/api/Dockerfile +++ b/api/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 e90637c227c5bc7d20825ba4c6f4975fbc9c64cb Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:18:32 +0000 Subject: [PATCH 09/20] 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 --- api/Dockerfile | 4 +- api/alembic/env.py | 6 +- .../versions/001_encrypt_session_data.py | 16 +- .../versions/002_better_auth_tables.py | 145 ++++++++++-------- ...003_make_users_hashed_password_nullable.py | 21 ++- api/alembic/versions/004_fix_user_id_text.py | 16 +- api/src/cartsnitch_api/models/user.py | 12 +- 7 files changed, 147 insertions(+), 73 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 23166b8..e3b4bbf 100644 --- a/api/Dockerfile +++ b/api/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/api/alembic/env.py b/api/alembic/env.py index 3e563e1..5104dca 100644 --- a/api/alembic/env.py +++ b/api/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/api/alembic/versions/001_encrypt_session_data.py b/api/alembic/versions/001_encrypt_session_data.py index 4932231..20c70ac 100644 --- a/api/alembic/versions/001_encrypt_session_data.py +++ b/api/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/api/alembic/versions/002_better_auth_tables.py b/api/alembic/versions/002_better_auth_tables.py index aa5dd93..169ed38 100644 --- a/api/alembic/versions/002_better_auth_tables.py +++ b/api/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/api/alembic/versions/003_make_users_hashed_password_nullable.py b/api/alembic/versions/003_make_users_hashed_password_nullable.py index 8aec2bc..573b0ad 100644 --- a/api/alembic/versions/003_make_users_hashed_password_nullable.py +++ b/api/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/api/alembic/versions/004_fix_user_id_text.py b/api/alembic/versions/004_fix_user_id_text.py index a52bf9d..648333c 100644 --- a/api/alembic/versions/004_fix_user_id_text.py +++ b/api/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/api/src/cartsnitch_api/models/user.py b/api/src/cartsnitch_api/models/user.py index 89390a3..9cbd4e8 100644 --- a/api/src/cartsnitch_api/models/user.py +++ b/api/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 be75c7f254d41fe986794e250f28c08e352fb192 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:39:27 +0000 Subject: [PATCH 10/20] 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 --- api/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/api/alembic/versions/002_better_auth_tables.py b/api/alembic/versions/002_better_auth_tables.py index 169ed38..efa283f 100644 --- a/api/alembic/versions/002_better_auth_tables.py +++ b/api/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/api/alembic/versions/005_add_email_inbound_token.py b/api/alembic/versions/005_add_email_inbound_token.py index 4fb7c2c..c5cc2a9 100644 --- a/api/alembic/versions/005_add_email_inbound_token.py +++ b/api/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/api/alembic/versions/006_email_inbound_token_server_default.py b/api/alembic/versions/006_email_inbound_token_server_default.py index ac1c678..e090016 100644 --- a/api/alembic/versions/006_email_inbound_token_server_default.py +++ b/api/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 7bf0165fe4185b48e62fb78cd480d2d85a56d31d Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 17:10:29 +0000 Subject: [PATCH 11/20] 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 --- api/alembic/env.py | 8 +++- .../versions/007_bootstrap_users_table.py | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/007_bootstrap_users_table.py diff --git a/api/alembic/env.py b/api/alembic/env.py index 504da75..b781b2f 100644 --- a/api/alembic/env.py +++ b/api/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/api/alembic/versions/007_bootstrap_users_table.py b/api/alembic/versions/007_bootstrap_users_table.py new file mode 100644 index 0000000..e9695c0 --- /dev/null +++ b/api/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 43ee1c3531211f14be3e1005918432dffcb9b95e Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 18:32:36 +0000 Subject: [PATCH 12/20] 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 --- api/alembic/env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/alembic/env.py b/api/alembic/env.py index b781b2f..38d7156 100644 --- a/api/alembic/env.py +++ b/api/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 def921f115b20204328c0acae67c7b6f8f6edda9 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 18:40:22 +0000 Subject: [PATCH 13/20] 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 --- api/src/cartsnitch_api/auth/dependencies.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 6fe1db4..91c438f 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/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 138033be9bbe054329f7bcd4e579eae7a205f80c Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:00:09 +0000 Subject: [PATCH 14/20] 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 --- api/src/cartsnitch_api/auth/dependencies.py | 8 ++++++-- api/tests/conftest.py | 7 +++++-- api/tests/test_auth/test_auth_endpoints.py | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 91c438f..4eac005 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/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/api/tests/conftest.py b/api/tests/conftest.py index 61810e1..647dbd9 100644 --- a/api/tests/conftest.py +++ b/api/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/api/tests/test_auth/test_auth_endpoints.py b/api/tests/test_auth/test_auth_endpoints.py index 7b096ae..83e49d7 100644 --- a/api/tests/test_auth/test_auth_endpoints.py +++ b/api/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 25edd8d5e3e3d1f9a198b5812773ced6d4d65c53 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:21:26 +0000 Subject: [PATCH 15/20] =?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 --- api/src/cartsnitch_api/auth/dependencies.py | 9 +++------ api/tests/conftest.py | 8 +++----- api/tests/test_auth/test_auth_endpoints.py | 4 +--- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 4eac005..390b40e 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/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/api/tests/conftest.py b/api/tests/conftest.py index 647dbd9..bb84c20 100644 --- a/api/tests/conftest.py +++ b/api/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/api/tests/test_auth/test_auth_endpoints.py b/api/tests/test_auth/test_auth_endpoints.py index 83e49d7..7b096ae 100644 --- a/api/tests/test_auth/test_auth_endpoints.py +++ b/api/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 ec9deb515b8ec1929a95a90b5342679255334839 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 19:52:24 +0000 Subject: [PATCH 16/20] 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 --- api/src/cartsnitch_api/config.py | 14 ++++++++-- api/tests/test_config.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 api/tests/test_config.py diff --git a/api/src/cartsnitch_api/config.py b/api/src/cartsnitch_api/config.py index 5111997..7642deb 100644 --- a/api/src/cartsnitch_api/config.py +++ b/api/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/api/tests/test_config.py b/api/tests/test_config.py new file mode 100644 index 0000000..f594bc2 --- /dev/null +++ b/api/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 3745f5be69e126f5453e9fa7fd12a48b6db85985 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 20:32:43 +0000 Subject: [PATCH 17/20] 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 --- api/src/cartsnitch_api/auth/dependencies.py | 7 ++- api/tests/test_auth/test_auth_endpoints.py | 50 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 390b40e..5040741 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/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/api/tests/test_auth/test_auth_endpoints.py b/api/tests/test_auth/test_auth_endpoints.py index 7b096ae..9b55a4c 100644 --- a/api/tests/test_auth/test_auth_endpoints.py +++ b/api/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 0be7ccd4b487ce46ed23665cbd8567e2abb8e65f Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 21:12:13 +0000 Subject: [PATCH 18/20] 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 --- api/alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/alembic/env.py b/api/alembic/env.py index 38d7156..d692a7f 100644 --- a/api/alembic/env.py +++ b/api/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 b2725fd512c7040801bb4d04401c2a7492a66cf9 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 19/20] fix(api): create domain tables migration + fix create_all commit Co-Authored-By: Paperclip --- api/alembic/env.py | 1 + .../versions/008_create_domain_tables.py | 210 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 api/alembic/versions/008_create_domain_tables.py diff --git a/api/alembic/env.py b/api/alembic/env.py index d692a7f..6844fba 100644 --- a/api/alembic/env.py +++ b/api/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/api/alembic/versions/008_create_domain_tables.py b/api/alembic/versions/008_create_domain_tables.py new file mode 100644 index 0000000..021c5bf --- /dev/null +++ b/api/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 2f1833e90de715545db7d9a6f2245e95e2244572 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Sat, 4 Apr 2026 21:36:05 +0000 Subject: [PATCH 20/20] 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 --- api/alembic/env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/alembic/env.py b/api/alembic/env.py index d692a7f..6844fba 100644 --- a/api/alembic/env.py +++ b/api/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(