Compare commits

..

26 Commits

Author SHA1 Message Date
Barcode Betty def921f115 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 <noreply@paperclip.ing>
2026-04-04 18:40:22 +00:00
cartsnitch-cto[bot] f03d7a33c8 Merge pull request #131 from cartsnitch/betty/fix-uat-users-table-bootstrap
fix(api): bootstrap users table in migration 007 + harden create_all
2026-04-04 17:34:32 +00:00
Barcode Betty 7bf0165fe4 fix(api): bootstrap users table in migration 007 + harden create_all
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 <noreply@paperclip.ing>
2026-04-04 17:10:29 +00:00
cartsnitch-cto[bot] ef63c47b7c fix(api): make alembic migrations idempotent for fresh databases (#129)
fix(api): make alembic migrations idempotent for fresh databases
2026-04-04 16:41:02 +00:00
Pawla Abdul be75c7f254 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 <noreply@paperclip.ing>
2026-04-04 16:39:27 +00:00
Pawla Abdul e90637c227 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 <noreply@paperclip.ing>
2026-04-04 16:28:29 +00:00
cartsnitch-cto[bot] 67e60c9ae1 Merge pull request #127 from cartsnitch/betty/fix-libpq5-dockerfile
fix: install libpq5 runtime in API prod Docker stage
2026-04-04 15:52:33 +00:00
Barcode Betty a25b673dd6 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 <noreply@paperclip.ing>
2026-04-04 10:51:57 +00:00
cartsnitch-cto[bot] 4e003ba3d0 Merge pull request #125 from cartsnitch/fix/alembic-percent-escape
fix(api): escape percent signs in alembic database URL
2026-04-04 06:36:51 +00:00
Barcode Betty 4996ff7432 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 <noreply@paperclip.ing>
2026-04-04 06:31:48 +00:00
cartsnitch-cto[bot] ffc6c7960d fix(api): add server_default to users.email_inbound_token (#123)
fix(api): add server_default to users.email_inbound_token
2026-04-04 06:23:34 +00:00
Pawla Abdul cf16415720 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 <noreply@paperclip.ing>
2026-04-04 06:17:37 +00:00
cartsnitch-cto[bot] 33f9e17339 fix(ci): use full SHA in docker/metadata-action tags (#121)
fix(ci): use full SHA in docker/metadata-action tags
2026-04-04 05:37:22 +00:00
cartsnitch-engineer[bot] 7639be9a41 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 <noreply@paperclip.ing>
2026-04-04 05:31:04 +00:00
cartsnitch-cto[bot] ebe439ce84 fix(ci): build and deploy from dev and uat branches
fix(ci): build and deploy from dev and uat branches
2026-04-04 04:59:40 +00:00
cartsnitch-engineer[bot] a663729121 fix(ci): build and deploy from dev and uat branches 2026-04-04 04:54:09 +00:00
cartsnitch-cto[bot] 4fc7933e30 Merge pull request #117 from cartsnitch/betty/fix-alembic-dockerfile
fix(api): include alembic config and migrations in Docker image
2026-04-04 04:44:47 +00:00
cartsnitch-engineer[bot] 6e0cb93ee2 fix(api): include alembic config and migrations in Docker image 2026-04-04 04:40:50 +00:00
cartsnitch-qa[bot] 0e4848f8b4 Merge pull request #115 from cartsnitch/betty/fix-uat-trustedorigins
fix(auth): add UAT hostname to trustedOrigins
2026-04-04 04:24:09 +00:00
Pawla Abdul bb7010f881 fix(auth): add UAT hostname to trustedOrigins
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 04:18:03 +00:00
cartsnitch-cto[bot] 4756e1c1c5 Merge pull request #114 from cartsnitch/feat/sync-common-email-inbound-token
feat(common): sync email_inbound_token from standalone common repo
2026-04-03 20:18:35 +00:00
Barcode Betty 73c038e406 feat(common): sync email_inbound_token from standalone repo 2026-04-03 20:12:35 +00:00
cartsnitch-cto[bot] 02e34d65bb fix(ci): use api/Dockerfile in build-and-push-api job
fix(ci): use api/Dockerfile in build-and-push-api job
2026-04-03 19:53:46 +00:00
cartsnitch-ceo[bot] a869bb42d7 fix(ci): use api/Dockerfile in build-and-push-api job
PR #111 fixed the build context to ./api but forgot to also update
the file path. The job was using ./Dockerfile (the frontend Dockerfile
which references nginx.conf and package-lock.json from the repo root),
causing the API image build to fail with a cache checksum error.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 19:49:28 +00:00
cartsnitch-cto[bot] d77d1b58b8 Merge pull request #112 from cartsnitch/fix/ci-deploy-race
fix(ci): add git pull --rebase to deploy jobs to prevent race condition
2026-04-03 17:22:21 +00:00
cartsnitch-engineer[bot] d86c0001eb fix(ci): add git pull --rebase to deploy jobs to prevent race condition 2026-04-03 17:19:57 +00:00
18 changed files with 454 additions and 114 deletions
+111 -33
View File
@@ -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 }}
@@ -146,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' }}
@@ -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 }}
@@ -215,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' }}
@@ -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 }}
@@ -271,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' }}
@@ -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 }}
@@ -327,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' }}
@@ -335,15 +339,15 @@ jobs:
uses: docker/build-push-action@v6
with:
context: ./api
file: ./Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
file: ./api/Dockerfile
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: |
@@ -399,12 +439,13 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main
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
@@ -429,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: |
@@ -460,4 +537,5 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main
+5 -1
View File
@@ -12,10 +12,14 @@ 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
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
USER 1000
EXPOSE 8000
@@ -23,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"]
+12 -2
View File
@@ -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:
@@ -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
@@ -47,6 +47,16 @@ 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.
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():
@@ -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()
+78 -65
View File
@@ -21,81 +21,94 @@ 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))
# 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 ---
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"))
@@ -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)
+15 -1
View File
@@ -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"))
@@ -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},
)
@@ -0,0 +1,42 @@
"""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:
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",
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,
)
@@ -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"))
+4 -2
View File
@@ -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
+10 -2
View File
@@ -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
+1
View File
@@ -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",
],
});
+1 -1
View File
@@ -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
@@ -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")
+11 -1
View File
@@ -1,10 +1,11 @@
"""User and UserStoreAccount models."""
import secrets
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
@@ -21,6 +22,15 @@ 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),
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))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
@@ -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
+34
View File
@@ -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(