Compare commits

..

19 Commits

Author SHA1 Message Date
cartsnitch-ceo[bot] 1267caf43c Release: domain tables migration + alembic fixes (UAT-verified)
Merging to production after full SDLC sign-off:
- UAT PASS: CAR-518 (Deal Dottie)
- UAT PASS: CAR-522 (Deal Dottie)
- Security PASS: CAR-518 PR #145 (Stockboy Steve)
- Security PASS: CAR-522 PR #148 (Stockboy Steve)
- CEO review: Coupon Carl

CI: lint  test  audit  e2e 
2026-04-05 02:55:12 +00:00
cartsnitch-cto[bot] 015401861a Merge pull request #150 from cartsnitch/dev
Promote dev→uat: alembic env.py connection.commit() fix
2026-04-04 21:58:13 +00:00
cartsnitch-cto[bot] 9891e1aefb Merge pull request #149 from cartsnitch/dev
promote(uat): domain tables migration + create_all commit fix
2026-04-04 21:37:02 +00:00
cartsnitch-cto[bot] 69ad161e36 Merge pull request #146 from cartsnitch/dev
chore: promote dev → uat (alembic model import fix)
2026-04-04 21:20:26 +00:00
cartsnitch-cto[bot] 485f890df3 Merge pull request #144 from cartsnitch/dev
Promote dev → uat: session cookie parsing fix (PR #143)
2026-04-04 20:39:25 +00:00
cartsnitch-cto[bot] bf3ed0ede3 Merge pull request #142 from cartsnitch/dev
chore: promote dev → uat (fix API DATABASE_URL fallback)
2026-04-04 20:06:06 +00:00
cartsnitch-cto[bot] 3f41eb7346 Merge pull request #140 from cartsnitch/dev
chore: promote dev → uat (revert SHA-256 session token hashing)
2026-04-04 19:25:42 +00:00
cartsnitch-qa[bot] 6cbd1ef298 chore: promote dev → UAT (SHA-256 session token hash fix) (#138)
chore: promote dev → UAT (SHA-256 session token hash fix)
2026-04-04 19:06:46 +00:00
cartsnitch-cto[bot] 94214f762e Merge pull request #137 from cartsnitch/dev
chore: promote dev to UAT (alembic version_table width fix)
2026-04-04 19:01:28 +00:00
cartsnitch-cto[bot] 562c6ef6f6 Promote to UAT: fix __Secure- session cookie prefix (#134)
Promote to UAT: fix __Secure- session cookie prefix (#134)
2026-04-04 18:48:44 +00:00
cartsnitch-cto[bot] ccc8189d88 Merge pull request #132 from cartsnitch/dev
Promote to UAT: bootstrap users table migration 007 + harden create_all
2026-04-04 17:34:53 +00:00
cartsnitch-cto[bot] 86594e4a8e Promote dev → UAT: idempotent alembic migrations (#130)
Promote dev → UAT: idempotent alembic migrations for fresh databases
2026-04-04 16:41:18 +00:00
cartsnitch-cto[bot] c2f1a83c1d Merge pull request #128 from cartsnitch/dev
Promote dev → uat: libpq5 runtime fix (PR #127)
2026-04-04 15:52:49 +00:00
cartsnitch-cto[bot] 6f8e5a9577 Merge pull request #126 from cartsnitch/dev
Promote dev→uat: alembic percent escape fix (PR #125)
2026-04-04 06:37:07 +00:00
cartsnitch-cto[bot] bbfa816e57 Promote dev → UAT: email_inbound_token server_default fix (#124)
Promote dev → UAT: email_inbound_token server_default fix
2026-04-04 06:23:48 +00:00
cartsnitch-cto[bot] 5904eb03a2 chore: promote dev → uat (CI sha_tag fix) (#122)
chore: promote dev → uat (CI sha_tag fix)
2026-04-04 05:37:41 +00:00
cartsnitch-cto[bot] 87b6433ff7 Promote to UAT: CI workflow fix for dev/uat branch builds
Promote to UAT: CI workflow fix for dev/uat branch builds (PR #119)
2026-04-04 05:07:42 +00:00
cartsnitch-cto[bot] d7c9938f7e Merge pull request #118 from cartsnitch/dev
promote: dev → uat (alembic Dockerfile fix, PR #117)
2026-04-04 04:45:02 +00:00
cartsnitch-qa[bot] 02434060ee Merge pull request #116 from cartsnitch/dev
Promote to UAT: fix(auth) trustedOrigins + latest dev
2026-04-04 04:24:26 +00:00
8 changed files with 30 additions and 129 deletions
@@ -1,38 +0,0 @@
"""Add GIN index on upc_variants and alter column to JSONB.
Revision ID: 009_add_gin_index_upc_variants
Revises: 008_create_domain_tables
Create Date: 2026-04-14
"""
import sqlalchemy as sa
from alembic import op
revision = "009_add_gin_index_upc_variants"
down_revision = "008_create_domain_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"normalized_products",
"upc_variants",
type_=sa.dialects.postgresql.JSONB(),
postgresql_using="upc_variants::jsonb",
)
op.create_index(
"ix_normalized_products_upc_variants_gin",
"normalized_products",
["upc_variants"],
postgresql_using="gin",
)
def downgrade() -> None:
op.drop_index("ix_normalized_products_upc_variants_gin", table_name="normalized_products")
op.alter_column(
"normalized_products",
"upc_variants",
type_=sa.JSON(),
)
+6 -22
View File
@@ -13,13 +13,14 @@ class Settings(BaseSettings):
) )
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
jwt_secret_key: str jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 15 jwt_access_token_expire_minutes: int = 15
jwt_refresh_token_expire_days: int = 7 jwt_refresh_token_expire_days: int = 7
service_key: str service_key: str = "change-me-in-production"
fernet_key: str # Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001" auth_service_url: str = "http://auth:3001"
@@ -34,26 +35,9 @@ class Settings(BaseSettings):
rate_limit_window_seconds: int = 60 rate_limit_window_seconds: int = 60
rate_limit_enabled: bool = True rate_limit_enabled: bool = True
_PLACEHOLDER_VALUES = {"change-me-in-production"}
@model_validator(mode="after") @model_validator(mode="after")
def validate_secrets(self): def validate_fernet_key(self):
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES: """Validate fernet_key is a valid 32-byte url-safe base64 key at startup."""
raise ValueError(
"CARTSNITCH_JWT_SECRET_KEY must be set to a secure value. "
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
if not self.service_key or self.service_key in self._PLACEHOLDER_VALUES:
raise ValueError(
"CARTSNITCH_SERVICE_KEY must be set to a secure value. "
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
if not self.fernet_key or self.fernet_key in self._PLACEHOLDER_VALUES:
raise ValueError(
"CARTSNITCH_FERNET_KEY must be set to a valid Fernet key. "
"Generate one with: python -c "
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
)
try: try:
decoded = base64.urlsafe_b64decode(self.fernet_key.encode()) decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
if len(decoded) != 32: if len(decoded) != 32:
+2 -2
View File
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_methods=["*"],
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"], allow_headers=["*"],
) )
+7 -34
View File
@@ -19,25 +19,6 @@ from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base from cartsnitch_api.models import Base
TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@pytest.fixture(autouse=True)
def setup_test_settings():
original_jwt = cartsnitch_settings.jwt_secret_key
original_service = cartsnitch_settings.service_key
original_fernet = cartsnitch_settings.fernet_key
cartsnitch_settings.jwt_secret_key = TEST_JWT_SECRET
cartsnitch_settings.service_key = TEST_SERVICE_KEY
cartsnitch_settings.fernet_key = TEST_FERNET_KEY
yield
cartsnitch_settings.jwt_secret_key = original_jwt
cartsnitch_settings.service_key = original_service
cartsnitch_settings.fernet_key = original_fernet
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@@ -79,8 +60,7 @@ async def db_engine():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models) # Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute( await conn.execute(text("""
text("""
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE, token TEXT NOT NULL UNIQUE,
@@ -91,10 +71,8 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
) )
""") """))
) await conn.execute(text("""
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -110,10 +88,8 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
) )
""") """))
) await conn.execute(text("""
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS verifications ( CREATE TABLE IF NOT EXISTS verifications (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
identifier TEXT NOT NULL, identifier TEXT NOT NULL,
@@ -122,8 +98,7 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
) )
""") """))
)
yield engine yield engine
@@ -158,9 +133,7 @@ async def client(db_engine):
app.dependency_overrides.clear() app.dependency_overrides.clear()
async def _create_test_user_and_session( async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
client: AsyncClient, db_engine, **user_overrides
) -> tuple[dict, str]:
"""Create a test user and a valid session directly in the DB. """Create a test user and a valid session directly in the DB.
Returns (user_dict, session_token). Better-Auth stores the raw token Returns (user_dict, session_token). Better-Auth stores the raw token
@@ -3,7 +3,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import JSON, String from sqlalchemy import JSON, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import ProductCategory, SizeUnit from cartsnitch_common.constants import ProductCategory, SizeUnit
@@ -27,9 +26,7 @@ class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
brand: Mapped[str | None] = mapped_column(String(200)) brand: Mapped[str | None] = mapped_column(String(200))
size: Mapped[str | None] = mapped_column(String(50)) size: Mapped[str | None] = mapped_column(String(50))
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10)) size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
upc_variants: Mapped[list[str] | None] = mapped_column( upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
JSON().with_variant(JSONB(), "postgresql"), default=list
)
# Relationships # Relationships
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product") purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
-6
View File
@@ -9,12 +9,6 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256; gzip_min_length 256;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.cartsnitch.com https://*.farh.net; frame-ancestors 'self'" always;
# Health endpoint for K8s probes # Health endpoint for K8s probes
location /health { location /health {
access_log off; access_log off;
+3 -3
View File
@@ -9805,9 +9805,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5,14 +5,12 @@ Matches products across retailers by:
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence) 2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
""" """
import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from cartsnitch_common.models.product import NormalizedProduct from cartsnitch_common.models.product import NormalizedProduct
from sqlalchemy import cast, func, select, String from sqlalchemy import select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -98,24 +96,17 @@ def jaccard_similarity(a: str, b: str) -> float:
def match_by_upc(session: Session, upc: str) -> MatchResult | None: def match_by_upc(session: Session, upc: str) -> MatchResult | None:
"""Find a normalized product by exact UPC match. """Find a normalized product by exact UPC match.
Uses PostgreSQL JSONB containment (@>) for production efficiency. Loads products with upc_variants and checks membership in Python
Falls back to LIKE on SQLite for test compatibility. for cross-database compatibility (works on both PostgreSQL and SQLite).
""" """
dialect_name = session.bind.dialect.name if session.bind else "default" # TODO: Use PostgreSQL JSON containment query (@>) for production.
if dialect_name == "postgresql": # Current approach loads all products into memory — acceptable for tests
stmt = select(NormalizedProduct).where( # and small datasets, but will not scale.
cast(NormalizedProduct.upc_variants, JSONB).op("@>")( stmt = select(NormalizedProduct).where(NormalizedProduct.upc_variants.is_not(None))
func.cast(json.dumps([upc]), JSONB) products = session.execute(stmt).scalars().all()
) for product in products:
) if product.upc_variants and upc in product.upc_variants:
else: return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
stmt = select(NormalizedProduct).where(
NormalizedProduct.upc_variants.is_not(None),
cast(NormalizedProduct.upc_variants, String).contains(upc),
)
product = session.execute(stmt).scalars().first()
if product:
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
return None return None