Compare commits

...

18 Commits

Author SHA1 Message Date
cartsnitch-cto[bot] 440f92e96e Merge branch 'main' into fix/frontend-api-routes 2026-04-01 03:08:44 +00:00
cartsnitch-ceo[bot] 97bbdf68a5 fix(api): hash session token before DB lookup to match Better-Auth storage
fix(api): hash session token before DB lookup to match Better-Auth storage
2026-04-01 02:49:07 +00:00
CartSnitch Engineer Bot 02e5bee390 fix(frontend): align API route paths with backend (alerts, price-history)
Change frontend to call /alerts (was /price-alerts) and /products/{id}/prices
(was /products/{id}/price-history) to match the backend router mounts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:10:12 +00:00
CartSnitch Engineer Bot d475b3876a fix(api): hash session token before DB lookup to match Better-Auth storage
Better-Auth v1.5.6+ stores session tokens as SHA-256 hashes in the
sessions table. The raw token from the cookie was being queried directly,
causing all authenticated /api/v1/* requests to return 401.

Fixes CAR-313.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:09:55 +00:00
cartsnitch-qa[bot] 76bcc53992 fix(api): mount data routers under /api/v1 prefix
Merges fix for CAR-310 / CAR-161 UAT failure. QA approved, CTO approved, CI green.
2026-04-01 01:50:20 +00:00
cartsnitch-qa[bot] 470b615528 Merge branch 'main' into fix/api-v1-prefix 2026-04-01 01:45:37 +00:00
CartSnitch Engineer Bot f26f8f7e56 fix(api): mount data routers under /api/v1 prefix
Fixes CAR-161 UAT failure: k8s HTTPRoute forwards /api/* to the API
gateway without path rewriting, so requests arrive at FastAPI as
/api/v1/purchases, /api/v1/products, etc. FastAPI previously mounted
data routers at root, causing 404s on all /api/v1/* calls.

Keep health and auth routers at root (probes hit /health directly;
auth traffic is routed to the auth service via HTTPRoute).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 23:56:20 +00:00
cartsnitch-ceo[bot] 78b7831d43 Merge pull request #91 from cartsnitch/fix/registration-redirect
fix(auth): wait for session confirmation before post-auth redirect
2026-03-31 23:14:04 +00:00
CartSnitch Engineer Bot e45b510519 Merge commit '8af7b37b38f3d5c5cb13b3e98530ec4d6127b755' into fix/registration-redirect 2026-03-31 23:08:22 +00:00
CartSnitch Engineer Bot f25044ea7e fix(auth): restore setAuthenticated in mock-auth catch block
The try-block getSession() pattern is correct for real auth mode.
The mock-auth catch block (VITE_MOCK_AUTH) still needs to set
the Zustand flag so ProtectedRoute respects the authenticated state.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:30:05 +00:00
CartSnitch Engineer Bot b637fd9c11 fix(auth): wait for session confirmation before post-auth redirect
Race condition between signUp/signIn completion and ProtectedRoute's
useSession() call caused redirect loops — Better-Auth's session cookie
is not immediately visible to useSession() after signUp/signIn resolves.

Fix: call authClient.getSession() explicitly after signUp/signIn to
synchronize before navigating to protected routes. Fall back to error
message if session not confirmed.

Also removes dead setAuthenticated() calls that only work in mock mode.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:11:20 +00:00
cartsnitch-engineer[bot] 983ee2c398 fix(ci): disable FullPageScreenshot gatherer to prevent Chrome crash
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 21:58:30 +00:00
cartsnitch-engineer[bot] 8af7b37b38 fix(api): run Alembic migrations on startup (#90)
Merged by Coupon Carl (CEO). QA approved, CTO approved. CI green (lighthouse failure is known/tracked). cc @cpfarhood
2026-03-31 21:55:00 +00:00
Barcode Betty b21a30b2e7 fix(ci): skip bf-cache audit to prevent Chrome TARGET_CRASHED in CI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 21:17:32 +00:00
Barcode Betty 361ad3acc2 fix(ci): add --disable-gpu and --disable-dev-shm-usage to Lighthouse Chrome flags 2026-03-31 21:07:44 +00:00
Stockboy Steve 5e165d277e fix(ci): add Chrome sandbox flags and fix CHROME_PATH for Lighthouse
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 20:48:19 +00:00
cartsnitch-ceo[bot] 6828e4d0a9 fix: change users.id and FK columns from uuid to text for Better-Auth (#88)
fix: change users.id and FK columns from uuid to text for Better-Auth
2026-03-31 19:20:01 +00:00
Stockboy Steve 00b2b2469b fix: change users.id and FK columns from uuid to text for Better-Auth compatibility
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table used PostgreSQL uuid type, causing registration failures:
  ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"

Changes:
- User.id: removed UUIDPrimaryKeyMixin, use explicit text PK
- UserStoreAccount.user_id: Mapped[uuid.UUID] -> Mapped[str]
- Purchase.user_id: Mapped[uuid.UUID] -> Mapped[str]
- UserResponse schema: id field from UUID -> str
- New Alembic migration 004_fix_user_id_text: drops FKs, alters column
  types, re-adds FKs (using id::text cast)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:56:13 +00:00
14 changed files with 178 additions and 29 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
build-and-push:
runs-on: runners-cartsnitch
+1 -1
View File
@@ -30,4 +30,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"]
@@ -0,0 +1,122 @@
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
a new user, Postgres throws:
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
The sessions, accounts, and verifications tables already use text IDs — only users,
user_store_accounts.user_id, and purchases.user_id needed fixing.
Revision ID: 004_fix_user_id_text
Revises: 003_make_users_hashed_password_nullable
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "004_fix_user_id_text"
down_revision = "003_make_users_hashed_password_nullable"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Drop existing FK constraints
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"))
# Step 2: Alter users.id from uuid to text
op.alter_column(
"users",
"id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="id::text",
)
# Step 3: Alter user_store_accounts.user_id from uuid to text
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 4: Alter purchases.user_id from uuid to text
op.alter_column(
"purchases",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 5: Re-add FK constraints
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
def downgrade() -> None:
# Drop FK constraints
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"))
# Revert users.id from text to uuid
op.alter_column(
"users",
"id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="id::uuid",
)
# Revert user_store_accounts.user_id from text to uuid
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Revert purchases.user_id from text to uuid
op.alter_column(
"purchases",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Re-add FK constraints (PostgreSQL will auto-name them)
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
+5 -1
View File
@@ -5,6 +5,7 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from hashlib import sha256
from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
@@ -27,10 +28,13 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as UUID) if the session is valid and not expired.
Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the
incoming raw token before querying.
"""
hashed_token = sha256(token.encode("utf-8")).hexdigest()
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
{"token": hashed_token},
)
row = result.first()
+14 -10
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.middleware.cors import add_cors_middleware
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
# Routers
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(stores_router)
app.include_router(purchases_router)
app.include_router(products_router)
app.include_router(prices_router)
app.include_router(coupons_router)
app.include_router(shopping_router)
app.include_router(alerts_router)
app.include_router(scraping_router)
app.include_router(public_router)
# Data endpoints mounted under /api/v1
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(stores_router)
v1_router.include_router(purchases_router)
v1_router.include_router(products_router)
v1_router.include_router(prices_router)
v1_router.include_router(coupons_router)
v1_router.include_router(shopping_router)
v1_router.include_router(alerts_router)
v1_router.include_router(scraping_router)
v1_router.include_router(public_router)
app.include_router(v1_router)
return app
+1 -1
View File
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases"
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
+4 -3
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus
@@ -16,11 +16,12 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class User(TimestampMixin, Base):
"""Application user."""
__tablename__ = "users"
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)
display_name: Mapped[str | None] = mapped_column(String(100))
@@ -36,7 +37,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+1 -1
View File
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel):
id: UUID
id: str
email: str
display_name: str
created_at: datetime
+7 -2
View File
@@ -3,7 +3,12 @@
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost:4173/"],
"numberOfRuns": 1
"numberOfRuns": 1,
"settings": {
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
"skipAudits": ["bf-cache"],
"disableFullPageScreenshot": true
}
},
"assert": {
"assertions": {
@@ -16,4 +21,4 @@
"target": "temporary-public-storage"
}
}
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
export function usePriceHistory(productId: string) {
return useQuery({
queryKey: ['priceHistory', productId],
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
enabled: !!productId,
})
}
@@ -50,6 +50,6 @@ export function useCoupons() {
export function usePriceAlerts() {
return useQuery({
queryKey: ['priceAlerts'],
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
queryFn: () => api.get<PriceAlert[]>('/alerts'),
})
}
+2 -2
View File
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
'/alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
+8 -2
View File
@@ -31,8 +31,14 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed')
}
setAuthenticated(true)
navigate('/')
// After successful signIn, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
setError('Sign in failed. Please try again.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
+9 -2
View File
@@ -38,8 +38,15 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed')
}
setAuthenticated(true)
navigate('/')
// After successful signUp, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
// Session not established — show success message and link to login
setError('Account created! Please sign in.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
+1 -1
View File
@@ -61,5 +61,5 @@ export const handlers = [
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
]