Compare commits

..

114 Commits

Author SHA1 Message Date
savannah-savings-cto[bot] f96daceb0f Merge pull request #235 from cartsnitch/betty/car-749-remove-auth-ci
fix(ci): remove auth image build from monorepo CI
2026-04-20 18:01:07 +00:00
Test User 0c5cce2adc fix(ci): remove auth image build — now handled by cartsnitch/auth repo
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-20 16:07:43 +00:00
savannah-savings-cto[bot] e3a0d94236 release: sign-in redirect fix (CAR-741/CAR-743)
release: sign-in redirect fix (CAR-741/CAR-743)
2026-04-19 16:45:39 +00:00
savannah-savings-cto[bot] 3f03d46ff5 promote: dev → uat (sign-in redirect fix, CAR-741)
promote: dev → uat (sign-in redirect fix, CAR-741)
2026-04-19 16:15:31 +00:00
savannah-savings-cto[bot] c0c4acb73f fix: resolve sign-in redirect race condition in Login.tsx (CAR-741)
fix: resolve sign-in redirect race condition in Login.tsx (CAR-741)
2026-04-19 16:15:10 +00:00
Barcode Betty a35c264823 fix: resolve sign-in redirect race condition in Login.tsx
Replace React Router navigate() with window.location.href = '/' after
successful sign-in. Better-Auth's useSession() hasn't updated its
internal cache when navigate() fires, causing ProtectedRoute to see a
null session and redirect back to /login. A full page reload
reinitializes useSession() with fresh cookie-backed session state.

Also remove the VITE_MOCK_AUTH fallback block that used
setAuthenticated() since the mock auth flow now goes through the same
window.location.href path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 16:09:33 +00:00
cartsnitch-ceo[bot] 63752fe5cb release: fix HIGH-severity CVEs in receiptwitness image (UAT+Security PASS)
release: fix HIGH-severity CVEs in receiptwitness image (UAT+Security PASS)
2026-04-19 02:40:14 +00:00
cartsnitch-cto[bot] 9ab585f336 Merge pull request #228 from cartsnitch/dev
chore: promote dev to UAT — receiptwitness CVE fixes
2026-04-19 02:19:20 +00:00
cartsnitch-cto[bot] 78b3a71450 Merge pull request #227 from cartsnitch/fix/car-709-receiptwitness-grype-cves
fix: resolve HIGH-severity CVEs in receiptwitness image
2026-04-19 02:17:54 +00:00
Test User 3216e6a1c2 fix: resolve HIGH-severity CVEs in receiptwitness image
- Bump cryptography>=46.0 to fix GHSA-r6ph-v2qm-q3c2
- Increment APT_CACHE_BUST to 1 to force fresh apt-get upgrade
  for OpenSSL/libssl3t64 (fixes CVE-2026-2673, CVE-2026-28388,
  CVE-2026-28389, CVE-2026-28390, CVE-2026-31790)
- Add 89 Chrome CVEs to grype.yaml ignore (Playwright bundles
  Chromium — CVEs can only be resolved by upgrading Playwright)
- Add node CVE-2026-21710 to grype.yaml ignore (Playwright
  bundled tooling dependency)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:48:02 +00:00
cartsnitch-ceo[bot] a66583b883 release: bcrypt cost factor 10→12, Grype CVE ignores, Dockerfile cache-bust (UAT+Security PASS)
release: bcrypt cost factor 10→12, Grype CVE ignores, Dockerfile cache-bust (UAT+Security PASS)
2026-04-19 00:24:10 +00:00
cartsnitch-cto[bot] 4a7d5131fc Merge pull request #225 from cartsnitch/dev
Promote dev to UAT: bcrypt cost factor fix
2026-04-19 00:04:07 +00:00
cartsnitch-cto[bot] 56b1ff9a36 Merge pull request #220 from cartsnitch/fix/car-656-deploy-commit-guard
fix(deploy): guard commit step against no-op changes (CAR-674)
2026-04-19 00:03:32 +00:00
cartsnitch-cto[bot] b660336897 Merge pull request #215 from cartsnitch/fix/car-663-bcrypt-cost-factor
fix: increase bcrypt cost factor from 10 to 12
2026-04-19 00:02:28 +00:00
cartsnitch-ceo[bot] af713f422b chore: promote UAT to production (CAR-690, Grype CVE ignores + cache-bust)
chore: promote UAT to production (CAR-690, Grype CVE ignores + cache-bust)
2026-04-18 23:59:42 +00:00
cartsnitch-cto[bot] 55ab0b7ceb Merge pull request #223 from cartsnitch/dev
chore: promote dev to UAT (Grype ignores + cache-bust)
2026-04-18 03:55:23 +00:00
cartsnitch-cto[bot] 93a94e9777 Merge pull request #214 from cartsnitch/fix/car-620-grype-ignore-and-cache-bust
fix: add Grype CVE ignores and cache-bust Debian apt-get upgrade layers
2026-04-18 03:55:06 +00:00
Barcode Betty 1bb669f3ca fix: add Grype CVE ignores and cache-bust Debian apt-get upgrade layers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 21:53:34 +00:00
Barcode Betty 82978f072b fix(deploy): guard commit step against no-op changes
Guard the infra commit step in deploy-dev and deploy-uat jobs with
`git diff --cached --quiet` to prevent CI failure when kustomization
has no actual image tag changes.

Refs: CAR-674
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 21:51:46 +00:00
Barcode Betty 9ba745b5a9 fix: increase bcrypt cost factor from 10 to 12
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 21:50:09 +00:00
Barcode Betty c13e640864 fix: add Grype CVE ignores and cache-bust Debian apt-get upgrade layers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 21:50:09 +00:00
cartsnitch-engineer[bot] c7b7494151 fix: e2e route mocking and color contrast accessibility (#221)
Fixes CAR-673, CAR-676. Replaces VITE_MOCK_AUTH with Playwright route mocking for all e2e tests. Fixes color contrast (text-gray-400 → text-gray-600).
2026-04-15 21:49:55 +00:00
cartsnitch-ceo[bot] f023480100 chore: promote UAT to production (CAR-662, audit logging middleware)
chore: promote UAT to production (CAR-662, audit logging middleware)
2026-04-15 04:29:39 +00:00
cartsnitch-ceo[bot] 9acaf5e83a Merge branch 'main' into uat 2026-04-15 04:17:24 +00:00
cartsnitch-cto[bot] 4e10c75fd0 Merge pull request #217 from cartsnitch/dev
Promote to UAT: ESLint lint fix (PR #216)
2026-04-15 04:04:25 +00:00
cartsnitch-cto[bot] ffdc26cce5 Merge pull request #216 from cartsnitch/fix/car-665-eslint-unused-vars
fix: remove unused navigate variable from Register.tsx
2026-04-15 03:59:45 +00:00
Barcode Betty 2e96e8f0a7 fix: remove unused navigate variable from Register.tsx
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 03:57:01 +00:00
cartsnitch-cto[bot] 88ac74e94c Merge pull request #213 from cartsnitch/dev
Promote to UAT: vite, mock-auth, Redis rate-limit, Redis cache, email verification
2026-04-15 03:33:42 +00:00
cartsnitch-ceo[bot] 66279716ba feat(auth): enable email verification with Resend (#173)
feat(auth): enable email verification with Resend
2026-04-15 03:32:23 +00:00
cartsnitch-ceo[bot] 15ab4ed38c feat(api): implement Redis cache get/set/delete with TTL support (#195)
feat(api): implement Redis cache get/set/delete with TTL support
2026-04-15 03:32:11 +00:00
cartsnitch-ceo[bot] fbd77a9434 fix: remove VITE_MOCK_AUTH bypass from production code (#193)
fix: remove VITE_MOCK_AUTH bypass from production code
2026-04-15 03:32:02 +00:00
cartsnitch-ceo[bot] fef5e86645 feat: Redis-backed rate limiting with stricter auth limits (#194)
feat: Redis-backed rate limiting with stricter auth limits
2026-04-15 03:31:42 +00:00
cartsnitch-ceo[bot] cf39ed1dcd fix: update vite to 6.4.2 to patch high-severity vulnerabilities (#191)
fix: update vite to 6.4.2 to patch high-severity vulnerabilities
2026-04-15 03:31:34 +00:00
Barcode Betty 71e2978f52 Enable Better-Auth email verification with Resend
- Add emailVerification.sendVerificationEmail config to auth/src/auth.ts
  using Resend to send verification emails on sign-up
- Add resend npm package to auth/package.json
- Update auth/.env.example with RESEND_API_KEY and FROM_EMAIL
- Create VerifyEmail.tsx page with token verification flow,
  spinner UX, success/Error states, and resend option
- Update Register.tsx to redirect to /verify-email after signup
  instead of auto-navigating to dashboard
- Add /verify-email route to App.tsx
- Frontend shows 'check your email' step after registration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 03:30:48 +00:00
Barcode Betty 4945ac71ae feat(auth): enable email verification with Resend
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 03:30:44 +00:00
cartsnitch-cto[bot] 53ffef0ed1 Merge pull request #212 from cartsnitch/dev
Promote to UAT: input validation + audit logging (PR #171, #183)
2026-04-15 03:30:04 +00:00
cartsnitch-ceo[bot] 5308923136 feat(api): add input validation on public endpoints (#171)
feat(api): add input validation on public endpoints
2026-04-15 03:26:38 +00:00
cartsnitch-ceo[bot] bdaca519f6 feat: implement audit logging middleware for sensitive API operations (#183)
feat: implement audit logging middleware for sensitive API operations
2026-04-15 03:23:37 +00:00
cartsnitch-cto[bot] cfad4eab37 Merge pull request #211 from cartsnitch/dev
Promote to UAT: bcrypt upgrade + Grype only-fixed filter (CAR-622)
2026-04-15 03:22:50 +00:00
cartsnitch-cto[bot] 90e23ac592 fix: upgrade bcrypt and filter unfixed CVEs in Grype scans (#207)
fix: upgrade bcrypt and filter unfixed CVEs in Grype scans
2026-04-15 03:18:13 +00:00
cartsnitch-ceo[bot] d8e7a416d2 chore: promote UAT to production (CAR-630)
Promotes UAT to main including PR #209 (N+1 UPC query fix with SQL containment).

UAT regression: passed (Deal Dottie)
Security review: passed (Stockboy Steve)
CI required checks: all green

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 02:16:12 +00:00
Barcode Betty c03e599ae3 feat: Redis-backed rate limiting with stricter auth limits
- Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60) settings
- Add rate_limit_redis_enabled flag for opt-in Redis usage
- Refactor _SlidingWindowCounter into InMemorySlidingWindow class
- Add RedisSlidingWindow using sorted sets with fallback to in-memory
- Add third _auth_strict_limiter for POST /auth/* paths (5 req/min)
- Add protocol-based backend selection at module load time
- Update tests for auth strict limiter and Redis fallback behavior

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 02:10:02 +00:00
cartsnitch-cto[bot] f051e4b4af chore: promote dev to UAT
chore: promote dev to UAT
2026-04-15 02:00:15 +00:00
cartsnitch-cto[bot] 908ebde4c6 fix: replace N+1 UPC query with SQL containment in normalization (#175)
fix: replace N+1 UPC query with SQL containment in normalization
2026-04-15 02:00:04 +00:00
cartsnitch-ceo[bot] c715c0e47a chore: promote uat to production (Grype image vulnerability scanning)
Merges Grype-based container image vulnerability scanning and Docker CVE remediation to production.

- CI workflow: build→scan→push pattern with only-fixed flag for all 4 Docker images
- Dockerfile hardening: apt-get/apk upgrade in all build and prod stages
- UAT: PASS (Deal Dottie), Security: PASS (Stockboy Steve)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 01:14:35 +00:00
Paperclip a0eef27944 fix: upgrade bcrypt and filter unfixed CVEs in Grype scans 2026-04-15 00:51:53 +00:00
cartsnitch-cto[bot] c968088a3f Merge pull request #208 from cartsnitch/dev
promote: dev → uat (Grype only-fixed flag)
2026-04-15 00:46:24 +00:00
cartsnitch-cto[bot] bb50ddc85d Merge pull request #206 from cartsnitch/fix/car-620-grype-only-fixed
fix: add only-fixed flag to Grype scans to skip unfixable CVEs
2026-04-15 00:46:10 +00:00
Hugh Hackman bd2e8feff6 fix: add only-fixed flag to Grype scans to skip unfixable CVEs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:28:56 +00:00
cartsnitch-cto[bot] 2b32bfdfe1 chore: promote dev to UAT (CAR-616 Docker CVE remediation) (#205)
chore: promote dev to UAT (CAR-616 Docker CVE remediation)
2026-04-14 23:57:52 +00:00
cartsnitch-cto[bot] 1e8223caeb fix: remediate high-severity CVEs in Docker images (#204)
fix: remediate high-severity CVEs in Docker images
2026-04-14 23:57:40 +00:00
Paperclip e1d77d7789 fix: remediate high-severity CVEs in Docker images
- Add apk upgrade to frontend Dockerfile (build + prod stages)
- Add apk upgrade to auth Dockerfile (build + runtime stages)
- Add apt-get upgrade to api Dockerfile (build + prod stages)
- Add apt-get upgrade to receiptwitness Dockerfile (build + prod stages)
- Run npm audit fix for frontend and auth dependencies

Refs: CAR-616
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 23:51:42 +00:00
cartsnitch-ceo[bot] 16200c5500 Merge branch 'main' into uat 2026-04-14 23:31:58 +00:00
cartsnitch-cto[bot] 1803d09095 Promote dev to UAT: Grype image vulnerability scanning
Promote dev to UAT: Grype image vulnerability scanning
2026-04-14 23:25:47 +00:00
cartsnitch-cto[bot] 8592701382 feat(ci): add Grype image vulnerability scanning to all Docker builds
feat(ci): add Grype image vulnerability scanning to all Docker builds
2026-04-14 23:25:17 +00:00
Paperclip 17447fb5e1 feat(ci): add Grype image vulnerability scanning to all Docker builds 2026-04-14 23:13:47 +00:00
cartsnitch-ceo[bot] e29bad9a39 chore: promote uat to production (auth health check DB connectivity fix) (#200)
chore: promote uat to production (auth health check DB connectivity fix)
2026-04-14 16:53:08 +00:00
cartsnitch-cto[bot] 349b519a00 Merge pull request #199 from cartsnitch/dev
chore: promote dev to uat (auth health check DB connectivity fix)
2026-04-14 16:39:50 +00:00
cartsnitch-cto[bot] b274fdff8e Merge pull request #198 from cartsnitch/fix/car-608-auth-health-check
fix: restore DB connectivity check to auth health endpoint
2026-04-14 16:39:18 +00:00
Paperclip a64dc7ab5e fix: restore DB connectivity check to auth health endpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:35:24 +00:00
cartsnitch-cto[bot] 7fc524b593 Merge pull request #197: promote dev to uat (auth config validation + vite audit fix)
chore: promote dev to uat (auth config validation + vite audit fix)
2026-04-14 16:19:27 +00:00
cartsnitch-cto[bot] 0fb99e6c16 Merge pull request #187 from cartsnitch/fix/auth-config-validation
fix: add startup validation to auth service config
2026-04-14 16:19:13 +00:00
Barcode Betty a53daddb9a fix: update vite to resolve high-severity audit vulnerability 2026-04-14 16:09:48 +00:00
cartsnitch-ceo[bot] 4e139dc4b6 Merge pull request #196 from cartsnitch/uat
chore: promote uat to main (ReceiptWitness config validation)
2026-04-14 16:08:05 +00:00
Paperclip 3351d74058 fix: add startup validation to auth service config
- Add DATABASE_URL validation after BETTER_AUTH_SECRET check
- Warn clearly when DATABASE_URL is not set (uses localhost default)
- Move pool declaration after validation blocks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:03:37 +00:00
Paperclip 1ce5d738d1 feat(api): implement Redis cache get/set/delete with TTL support
- Add async Redis client using redis-py with connection pooling
- Implement get/set/delete with graceful degradation when unavailable
- Add TTL support (default 300s) via SETEX
- Add cache invalidation hooks for price and product changes
- Use pattern-based SCAN for bulk invalidation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:00:35 +00:00
Paperclip e69b3c47be fix: update vite to resolve high-severity npm audit vulnerabilities 2026-04-14 15:56:33 +00:00
Paperclip 4c217757c3 feat: Redis-backed rate limiting with stricter auth limits
- Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60)
  settings to config.py
- Refactor rate_limit.py to use protocol/ABC pattern with InMemorySlidingWindow
  and RedisSlidingWindow implementations
- Add RedisSlidingWindow using sorted sets for distributed rate limiting
- Add auth_strict_limiter for /auth/* POST endpoints (5 req/min per IP)
- Fall back to in-memory when Redis is unavailable
- Update tests to cover new functionality

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 15:46:52 +00:00
Paperclip 121dc5724e fix: remove VITE_MOCK_AUTH bypass from production code
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 15:37:24 +00:00
Paperclip ee45400c7c fix: update vite to 6.4.2 to patch high-severity vulnerabilities
Vite 6.4.1 has two high-severity vulnerabilities:
- GHSA-4w7w-66w2-5vf9: Path Traversal in Optimized Deps .map Handling
- GHSA-p9ff-h696-f583: Arbitrary File Read via Vite Dev Server WebSocket

Updated to vite 6.4.2.

Fixes CAR-599.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 14:43:46 +00:00
Paperclip 1aff898545 fix: update vite to 6.4.2 to patch audit vulnerabilities
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 14:31:02 +00:00
cartsnitch-cto[bot] 6481cf03e4 Merge pull request #189 from cartsnitch/dev
chore: promote dev to uat (ReceiptWitness config validation)
2026-04-14 14:08:08 +00:00
cartsnitch-cto[bot] adfa34f2c2 Merge pull request #186 from cartsnitch/fix/receiptwitness-config-validation
fix: add startup validation to ReceiptWitness config
2026-04-14 14:07:48 +00:00
cartsnitch-ceo[bot] 37c75c3887 Production: API lifespan with connection pooling (CAR-550)
Production: API lifespan with connection pooling (CAR-550)
2026-04-14 14:00:08 +00:00
Paperclip ade03fdd1c fix: add startup validation to ReceiptWitness config
Add Pydantic model_validator to ReceiptWitnessSettings that fails fast
if session_encryption_key is missing or a placeholder value. Conditional
validation for resend_api_key when notifications_enabled=true.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 13:52:24 +00:00
cartsnitch-cto[bot] 8a0b2c03a1 Merge pull request #185 from cartsnitch/dev
Promote dev → uat: API lifespan with connection pooling (CAR-550)
2026-04-14 13:48:37 +00:00
cartsnitch-cto[bot] 5825174f0d Merge pull request #179 from cartsnitch/feature/cart-550-api-lifespan-pooling
feat(api): implement FastAPI lifespan with connection pooling (CAR-550)
2026-04-14 13:48:17 +00:00
Barcode Betty 6b75d4906f feat: implement audit logging middleware for sensitive API operations
- Add AuditMiddleware that logs POST/PUT/PATCH/DELETE and GET /auth/me
- Logs structured JSON: event, timestamp, user_id, method, path, client_ip, status_code, duration_ms
- Excludes health endpoints and OPTIONS requests
- Never logs request/response bodies or auth headers/cookies
- Wire user_id from auth dependency via request.state
- Add add_audit_middleware() to app factory

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 13:41:55 +00:00
cartsnitch-ceo[bot] aa893d9cc1 Release: rate limit key derivation fix + CORS security headers (#180)
Release: rate limit key derivation fix + CORS security headers
2026-04-14 13:25:23 +00:00
cartsnitch-ceo[bot] 91c062130c Merge branch 'main' into uat 2026-04-14 13:18:38 +00:00
Barcode Betty 68e6be1985 feat(api): implement FastAPI lifespan with connection pooling
- Add connection pool config to SQLAlchemy async engine (pool_size=10, max_overflow=20, pool_pre_ping, pool_recycle)
- Implement Redis connection pool in CacheClient with initialize/close lifecycle
- Wire lifespan startup/shutdown to initialize and dispose pools
- Add dispose_engine() for graceful DB pool cleanup on shutdown

Closes CAR-550

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 13:12:46 +00:00
cartsnitch-cto[bot] 0aef2455fd chore: promote dev to uat (CAR-557 rate limit fix) (#176)
chore: promote dev to uat (CAR-557 rate limit fix)
2026-04-14 12:45:29 +00:00
cartsnitch-cto[bot] c2a0263ddd fix(security): use SHA-256 hash for rate limit key instead of token suffix (#169)
fix(security): use SHA-256 hash for rate limit key instead of token suffix
2026-04-14 12:45:15 +00:00
CartSnitch Engineer Bot 24f0dd0e67 fix: replace N+1 UPC query with SQL containment in normalization
- Add PostgreSQL JSONB containment (@>) query for match_by_upc
- Add SQLite LIKE fallback for test compatibility
- Update upc_variants column to JSONB with variant for cross-db support
- Add GIN index migration for upc_variants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:59:28 +00:00
cartsnitch-cto[bot] 6602b8c105 Merge pull request #174 from cartsnitch/dev
CTO promoting dev→uat for CORS security headers.
2026-04-14 11:58:05 +00:00
cartsnitch-cto[bot] da96ec7dc4 Merge pull request #172 from cartsnitch/fix/cors-security-headers
CTO review: LGTM. CORS methods restricted to explicit list (no TRACE/CONNECT), headers whitelisted, nginx security headers added (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP). Clean diff, CI green.
2026-04-14 11:57:52 +00:00
CartSnitch Engineer Bot 37798251be fix: restrict CORS to explicit methods and add security headers
- Replace allow_methods=["*"] with explicit list: GET, POST, PUT, DELETE, PATCH, OPTIONS
- Replace allow_headers=["*"] with explicit list: Content-Type, Authorization, Accept, Origin, X-Requested-With
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP nginx headers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:49:02 +00:00
CartSnitch Engineer Bot cfea2586cb feat(api): add input validation on public endpoints
- Add days query param to GET /public/trends/{product_id} (ge=1, le=365)
- Add category query param to GET /public/store-comparison
- Add category and period query params to GET /public/inflation
- Add boundary and malicious input test cases

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:45:53 +00:00
CartSnitch Engineer Bot bc5e03e7a0 fix(security): use SHA-256 hash for rate limit key instead of token suffix
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:36:17 +00:00
cartsnitch-cto[bot] dbbc8d2e7b Merge pull request #168 from cartsnitch/dev
chore: promote dev to UAT (CAR-544 hardcoded secrets fix)
2026-04-14 11:31:54 +00:00
cartsnitch-cto[bot] ee97f64db6 Merge pull request #156 from cartsnitch/fix/hardcoded-secrets
fix: remove hardcoded default secrets from API config
2026-04-14 11:31:40 +00:00
CartSnitch Engineer Bot 538a5f4f4d fix: remove hardcoded default secrets from API config
Remove dangerous default values for jwt_secret_key, service_key, and
fernet_key. Add startup validation that raises RuntimeError if these
secrets are not set via environment variables or contain placeholder
values.

Add test fixture to provide explicit test values for these secrets,
ensuring existing tests continue to pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:11:23 +00:00
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] 4485bf1d5e Merge pull request #148 from cartsnitch/betty/fix-alembic-create-all-commit
fix(api): commit after create_all in alembic env.py
2026-04-04 21:57:54 +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] f7bf767da5 Merge pull request #147 from cartsnitch/betty/car-517-domain-tables-migration
CTO review: APPROVED. Migration creates all 9 domain tables in correct FK order with idempotent guards. env.py commit fix resolves SQLAlchemy 2.0 DDL persistence issue.
2026-04-04 21:36:48 +00:00
cartsnitch-engineer[bot] b2725fd512 fix(api): create domain tables migration + fix create_all commit
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 21:22:24 +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
48 changed files with 1733 additions and 943 deletions
+112 -103
View File
@@ -13,11 +13,11 @@ concurrency:
permissions:
contents: write
packages: write
security-events: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch
AUTH_IMAGE_NAME: cartsnitch/auth
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
API_IMAGE_NAME: cartsnitch/api
@@ -151,85 +151,52 @@ jobs:
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan frontend image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload frontend scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
- name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
build-and-push-auth:
runs-on: runners-cartsnitch
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:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
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'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (auth)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: |
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' }}
- name: Build and push auth Docker image
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
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'
@@ -278,14 +245,49 @@ jobs:
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push receiptwitness image
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./receiptwitness/Dockerfile
push: ${{ github.event_name == 'push' }}
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan receiptwitness image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload receiptwitness scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: ./receiptwitness/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
build-and-push-api:
runs-on: runners-cartsnitch
@@ -335,18 +337,53 @@ jobs:
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push API Docker image
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
push: ${{ github.event_name == 'push' }}
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan api image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload api scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
needs: [build-and-push, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
@@ -387,21 +424,6 @@ jobs:
cd infra/apps/overlays/dev
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:${{ steps.auth_tag.outputs.tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
@@ -438,13 +460,14 @@ jobs:
git config user.name "cartsnitch-ci[bot]"
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 diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(dev): update cartsnitch, 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]
needs: [build-and-push, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
@@ -485,21 +508,6 @@ jobs:
cd infra/apps/overlays/uat
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:${{ steps.auth_tag.outputs.tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
@@ -536,6 +544,7 @@ jobs:
git config user.name "cartsnitch-ci[bot]"
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 diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(uat): update cartsnitch, receiptwitness, and api images"
git pull --rebase origin main
git push origin main
+108
View File
@@ -0,0 +1,108 @@
ignore:
# Python 3.12 CVEs — only fixed in 3.13+, cannot upgrade major version safely
- vulnerability: CVE-2025-13836
- vulnerability: CVE-2026-4519
# Chrome CVEs — Playwright bundles Chromium and controls version separately.
# Chrome is not a system package that can be upgraded via apt-get upgrade.
# These CVEs are specific to the Chromium version bundled with Playwright.
# Upstream fix: upgrade Playwright to a version that includes patched Chrome.
- vulnerability: CVE-2026-2313
- vulnerability: CVE-2026-2314
- vulnerability: CVE-2026-2315
- vulnerability: CVE-2026-2319
- vulnerability: CVE-2026-2321
- vulnerability: CVE-2026-2441
- vulnerability: CVE-2026-2648
- vulnerability: CVE-2026-2649
- vulnerability: CVE-2026-2650
- vulnerability: CVE-2026-3061
- vulnerability: CVE-2026-3062
- vulnerability: CVE-2026-3536
- vulnerability: CVE-2026-3537
- vulnerability: CVE-2026-3538
- vulnerability: CVE-2026-3539
- vulnerability: CVE-2026-3540
- vulnerability: CVE-2026-3541
- vulnerability: CVE-2026-3542
- vulnerability: CVE-2026-3543
- vulnerability: CVE-2026-3544
- vulnerability: CVE-2026-3545
- vulnerability: CVE-2026-3913
- vulnerability: CVE-2026-3914
- vulnerability: CVE-2026-3915
- vulnerability: CVE-2026-3916
- vulnerability: CVE-2026-3917
- vulnerability: CVE-2026-3918
- vulnerability: CVE-2026-3919
- vulnerability: CVE-2026-3920
- vulnerability: CVE-2026-3921
- vulnerability: CVE-2026-3922
- vulnerability: CVE-2026-3923
- vulnerability: CVE-2026-3924
- vulnerability: CVE-2026-3926
- vulnerability: CVE-2026-3931
- vulnerability: CVE-2026-3932
- vulnerability: CVE-2026-3936
- vulnerability: CVE-2026-5858
- vulnerability: CVE-2026-5859
- vulnerability: CVE-2026-5860
- vulnerability: CVE-2026-5861
- vulnerability: CVE-2026-5862
- vulnerability: CVE-2026-5863
- vulnerability: CVE-2026-5865
- vulnerability: CVE-2026-5866
- vulnerability: CVE-2026-5868
- vulnerability: CVE-2026-5870
- vulnerability: CVE-2026-5871
- vulnerability: CVE-2026-5872
- vulnerability: CVE-2026-5873
- vulnerability: CVE-2026-5874
- vulnerability: CVE-2026-5877
- vulnerability: CVE-2026-5879
- vulnerability: CVE-2026-5883
- vulnerability: CVE-2026-5884
- vulnerability: CVE-2026-5902
- vulnerability: CVE-2026-5904
- vulnerability: CVE-2026-5907
- vulnerability: CVE-2026-5908
- vulnerability: CVE-2026-5909
- vulnerability: CVE-2026-5910
- vulnerability: CVE-2026-5912
- vulnerability: CVE-2026-5913
- vulnerability: CVE-2026-5914
- vulnerability: CVE-2026-5915
- vulnerability: CVE-2026-6296
- vulnerability: CVE-2026-6297
- vulnerability: CVE-2026-6299
- vulnerability: CVE-2026-6300
- vulnerability: CVE-2026-6301
- vulnerability: CVE-2026-6302
- vulnerability: CVE-2026-6303
- vulnerability: CVE-2026-6304
- vulnerability: CVE-2026-6305
- vulnerability: CVE-2026-6306
- vulnerability: CVE-2026-6307
- vulnerability: CVE-2026-6308
- vulnerability: CVE-2026-6309
- vulnerability: CVE-2026-6310
- vulnerability: CVE-2026-6311
- vulnerability: CVE-2026-6314
- vulnerability: CVE-2026-6315
- vulnerability: CVE-2026-6316
- vulnerability: CVE-2026-6317
- vulnerability: CVE-2026-6318
- vulnerability: CVE-2026-6319
- vulnerability: CVE-2026-6358
- vulnerability: CVE-2026-6359
- vulnerability: CVE-2026-6360
- vulnerability: CVE-2026-6361
- vulnerability: CVE-2026-6363
# Node.js CVE — comes from Playwright's bundled tooling (playwright-core uses Node.js
# for its CLI). The system Node.js is not used by receiptwitness service.
# Fix requires upgrading Playwright to a version that ships with patched Node.js.
- vulnerability: CVE-2026-21710
# cryptography GHSA — fixed by upgrading to >=46.0 per requirements
- vulnerability: GHSA-r6ph-v2qm-q3c2
+4 -1
View File
@@ -1,6 +1,6 @@
# Stage 1: Build
FROM node:20-alpine AS build
RUN apk update && apk upgrade --no-cache
WORKDIR /app
COPY package.json package-lock.json ./
@@ -11,6 +11,9 @@ RUN npm run build
# Stage 2: Production — uses nginxinc/nginx-unprivileged which runs as non-root (UID 101)
FROM nginxinc/nginx-unprivileged:stable-alpine AS prod
USER root
RUN apk update && apk upgrade --no-cache
USER 101
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+4 -2
View File
@@ -1,6 +1,7 @@
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
ARG APT_CACHE_BUST=0
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
@@ -12,7 +13,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/*
ARG APT_CACHE_BUST=0
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN adduser --system --group --uid 1000 app
@@ -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")
@@ -0,0 +1,38 @@
"""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 -2
View File
@@ -69,7 +69,9 @@ async def get_current_user(
token: str | None = None
# 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)
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(
SESSION_COOKIE_NAME
)
if 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
@@ -86,7 +88,9 @@ async def get_current_user(
detail="Authentication required",
)
return await _validate_session_token(token, db)
user_id = await _validate_session_token(token, db)
request.state.user_id = user_id
return user_id
async def verify_service_key(x_service_key: str = Header()) -> None:
+58 -8
View File
@@ -1,26 +1,76 @@
"""Redis/DragonflyDB caching helpers."""
import redis.asyncio as redis
from cartsnitch_api.config import settings
class CacheClient:
"""Stub for Redis/DragonflyDB caching.
"""Redis/DragonflyDB caching with connection pooling.
Will be used for expensive queries: price trends, product comparisons.
Cache invalidation via Redis pub/sub events from other services.
"""
def __init__(self) -> None:
self.url = settings.redis_url
self._pool: redis.ConnectionPool | None = None
self._client: redis.Redis | None = None
async def initialize(self) -> None:
"""Initialize the Redis connection pool."""
self._pool = redis.ConnectionPool.from_url(
settings.redis_url,
max_connections=20,
decode_responses=True,
)
self._client = redis.Redis(connection_pool=self._pool)
async def close(self) -> None:
"""Close the Redis connection pool."""
if self._client:
await self._client.aclose()
if self._pool:
await self._pool.aclose()
async def get(self, key: str) -> str | None:
# TODO: implement with redis-py async
return None
if not self._client:
return None
return await self._client.get(key)
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
# TODO: implement with redis-py async
pass
if not self._client:
return
await self._client.set(key, value, ex=ttl_seconds)
async def delete(self, key: str) -> None:
# TODO: implement with redis-py async
pass
if not self._client:
return
await self._client.delete(key)
async def invalidate_price_cache(self, product_id: str) -> None:
"""Invalidate all price-related cache entries for a product."""
if not self._client:
return
pattern = f"price:*:{product_id}"
await self._delete_pattern(pattern)
async def invalidate_product_cache(self, product_id: str) -> None:
"""Invalidate the product detail cache entry."""
if not self._client:
return
await self._client.delete(f"product:{product_id}")
async def _delete_pattern(self, pattern: str) -> None:
"""Delete all keys matching a pattern using SCAN."""
if not self._client:
return
cursor = 0
while True:
cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100)
if keys:
await self._client.delete(*keys)
if cursor == 0:
break
cache_client = CacheClient()
+28 -7
View File
@@ -13,14 +13,13 @@ class Settings(BaseSettings):
)
redis_url: str = "redis://localhost:6379/0"
jwt_secret_key: str = "change-me-in-production"
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 15
jwt_refresh_token_expire_days: int = 7
service_key: str = "change-me-in-production"
# Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
service_key: str
fernet_key: str
auth_service_url: str = "http://auth:3001"
@@ -33,11 +32,31 @@ class Settings(BaseSettings):
rate_limit_requests: int = 60
rate_limit_window_seconds: int = 60
rate_limit_auth_requests: int = 5
rate_limit_auth_window_seconds: int = 60
rate_limit_redis_enabled: bool = True
rate_limit_enabled: bool = True
_PLACEHOLDER_VALUES = {"change-me-in-production"}
@model_validator(mode="after")
def validate_fernet_key(self):
"""Validate fernet_key is a valid 32-byte url-safe base64 key at startup."""
def validate_secrets(self):
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES:
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:
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
if len(decoded) != 32:
@@ -56,7 +75,9 @@ class Settings(BaseSettings):
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)
self.database_url = self.database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)
return self
+13 -1
View File
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from cartsnitch_api.config import settings
engine = create_async_engine(settings.database_url, echo=False)
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@@ -14,3 +21,8 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
yield session
async def dispose_engine() -> None:
"""Dispose the database engine, closing all pooled connections."""
await engine.dispose()
+7 -2
View File
@@ -5,9 +5,12 @@ from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.cache import cache_client
from cartsnitch_api.database import dispose_engine
from cartsnitch_api.middleware.cors import add_cors_middleware
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.routes.alerts import router as alerts_router
from cartsnitch_api.routes.coupons import router as coupons_router
from cartsnitch_api.routes.health import router as health_router
@@ -23,9 +26,10 @@ from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# TODO: initialize DB session pool, Redis connection, service clients
await cache_client.initialize()
yield
# TODO: cleanup connections
await cache_client.close()
await dispose_engine()
def create_app() -> FastAPI:
@@ -40,6 +44,7 @@ def create_app() -> FastAPI:
add_cors_middleware(app)
add_error_monitor_middleware(app)
add_rate_limit_middleware(app)
add_audit_middleware(app)
# Exception handlers
add_error_handlers(app)
@@ -0,0 +1,64 @@
"""Audit logging middleware for sensitive API operations.
Logs structured JSON for POST/PUT/PATCH/DELETE requests and GET /auth/me.
Never logs request bodies, response bodies, Authorization headers, or cookie values.
"""
import json
import logging
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("cartsnitch_api.audit")
HEALTH_PATHS = {"/health", "/healthz", "/ready"}
class AuditMiddleware(BaseHTTPMiddleware):
"""Middleware to log structured audit events for sensitive operations."""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable],
):
if request.method == "OPTIONS" or request.url.path in HEALTH_PATHS:
return await call_next(request)
method = request.method
path = request.url.path
is_sensitive_write = method in {"POST", "PUT", "PATCH", "DELETE"}
is_auth_me_read = method == "GET" and path == "/auth/me"
if not (is_sensitive_write or is_auth_me_read):
return await call_next(request)
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
user_id = getattr(request.state, "user_id", None)
client_ip = request.client.host if request.client else "unknown"
log_entry = {
"event": "audit",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"user_id": user_id,
"method": method,
"path": path,
"client_ip": client_ip,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
}
logger.info(json.dumps(log_entry))
return response
def add_audit_middleware(app: FastAPI) -> None:
app.add_middleware(AuditMiddleware)
+2 -2
View File
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
)
+101 -19
View File
@@ -4,18 +4,32 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
"""
import hashlib
import logging
import time
import uuid
from collections import defaultdict
from threading import Lock
from typing import Protocol
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from redis.asyncio import Redis, RedisError
from starlette.middleware.base import BaseHTTPMiddleware
from cartsnitch_api.config import settings
logger = logging.getLogger(__name__)
class _SlidingWindowCounter:
class RateLimitBackend(Protocol):
"""Protocol for rate limit backends."""
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
class InMemorySlidingWindow:
"""Thread-safe in-memory sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int) -> None:
@@ -24,13 +38,12 @@ class _SlidingWindowCounter:
self._hits: dict[str, list[float]] = defaultdict(list)
self._lock = Lock()
def is_allowed(self, key: str) -> tuple[bool, int, int]:
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
# Prune expired entries
self._hits[key] = [t for t in self._hits[key] if t > cutoff]
current_count = len(self._hits[key])
@@ -43,15 +56,84 @@ class _SlidingWindowCounter:
return True, remaining, 0
# Module-level counters — one for public (per-IP), one for auth (per-token)
_public_limiter = _SlidingWindowCounter(
max_requests=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
)
_auth_limiter = _SlidingWindowCounter(
max_requests=settings.rate_limit_requests * 5, # 300/min for authenticated users
window_seconds=settings.rate_limit_window_seconds,
)
class RedisSlidingWindow:
"""Redis-backed sliding window rate limiter using sorted sets."""
def __init__(self, redis: Redis, max_requests: int, window_seconds: int) -> None:
self.redis = redis
self.max_requests = max_requests
self.window_seconds = window_seconds
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
try:
now = time.monotonic()
cutoff = now - self.window_seconds
now_ms = int(now * 1000)
cutoff_ms = int(cutoff * 1000)
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, cutoff_ms)
pipe.zcard(key)
results = await pipe.execute()
current_count = results[1]
if current_count >= self.max_requests:
oldest = await self.redis.zrange(key, 0, 0, withscores=True)
if oldest:
retry_after = int((oldest[0][1] - cutoff) / 1000) + 1
else:
retry_after = self.window_seconds
return False, 0, retry_after
member = f"{now_ms}:{uuid.uuid4().hex[:8]}"
pipe = self.redis.pipeline()
pipe.zadd(key, {member: now_ms})
pipe.expire(key, self.window_seconds)
await pipe.execute()
remaining = self.max_requests - current_count - 1
return True, remaining, 0
except RedisError as e:
logger.warning("Redis rate limit error, falling back to in-memory: %s", e)
in_memory = InMemorySlidingWindow(self.max_requests, self.window_seconds)
return await in_memory.is_allowed(key)
_redis_client: Redis | None = None
_use_redis = False
if settings.rate_limit_redis_enabled:
try:
_redis_client = Redis.from_url(settings.redis_url)
_use_redis = True
logger.info("Rate limiting will use Redis at %s", settings.redis_url)
except Exception as e:
logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e)
_use_redis = False
if _use_redis and _redis_client:
_public_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds
)
_auth_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
)
_auth_strict_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
)
else:
_public_limiter = InMemorySlidingWindow(
settings.rate_limit_requests, settings.rate_limit_window_seconds
)
_auth_limiter = InMemorySlidingWindow(
settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
)
_auth_strict_limiter = InMemorySlidingWindow(
settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
)
def _get_client_ip(request: Request) -> str:
@@ -62,30 +144,30 @@ def _get_client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]:
"""Determine rate limit key and which limiter to use."""
if request.url.path.startswith("/public"):
return f"ip:{_get_client_ip(request)}", _public_limiter
# For authenticated endpoints, use Bearer token as key if present
if request.url.path.startswith("/auth/") and request.method == "POST":
return f"ip:{_get_client_ip(request)}", _auth_strict_limiter
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# Use last 16 chars of token as key to avoid storing full tokens
return f"token:{token[-16:]}", _auth_limiter
token_hash = hashlib.sha256(token.encode()).hexdigest()
return f"token:{token_hash}", _auth_limiter
# Fallback to IP for unauthenticated non-public endpoints
return f"ip:{_get_client_ip(request)}", _public_limiter
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip rate limiting when disabled (e.g. in tests) or for health checks
if not settings.rate_limit_enabled or request.url.path == "/health":
return await call_next(request)
key, limiter = _get_rate_limit_key(request)
allowed, remaining, retry_after = limiter.is_allowed(key)
allowed, remaining, retry_after = await limiter.is_allowed(key)
if not allowed:
return JSONResponse(
+14 -5
View File
@@ -18,10 +18,14 @@ router = APIRouter(prefix="/public", tags=["public"])
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)):
async def public_price_trend(
product_id: UUID,
days: int = Query(90, ge=1, le=365),
db: AsyncSession = Depends(get_db),
):
svc = PublicService(db)
try:
return await svc.get_trend(product_id)
return await svc.get_trend(product_id, days=days)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
@@ -31,6 +35,7 @@ async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db
@router.get("/store-comparison", response_model=PublicStoreComparisonResponse)
async def public_store_comparison(
product_ids: Annotated[list[UUID], Query(max_length=20)],
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
db: AsyncSession = Depends(get_db),
):
if not product_ids:
@@ -39,10 +44,14 @@ async def public_store_comparison(
detail="At least one product_id is required",
)
svc = PublicService(db)
return await svc.get_store_comparison(product_ids)
return await svc.get_store_comparison(product_ids, category=category)
@router.get("/inflation", response_model=PublicInflationResponse)
async def public_inflation(db: AsyncSession = Depends(get_db)):
async def public_inflation(
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
period: str = Query("all-time", pattern=r"^(all-time|1y|6m|3m|1m)$"),
db: AsyncSession = Depends(get_db),
):
svc = PublicService(db)
return await svc.get_inflation()
return await svc.get_inflation(category=category, period=period)
+42 -23
View File
@@ -1,5 +1,6 @@
"""Public service — unauthenticated price transparency endpoints."""
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import and_, func, select
@@ -13,7 +14,7 @@ class PublicService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_trend(self, product_id: UUID) -> dict:
async def get_trend(self, product_id: UUID, days: int = 90) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
result = await self.db.execute(
@@ -23,9 +24,13 @@ class PublicService:
if not product:
raise LookupError("Product not found")
date_threshold = date.today() - timedelta(days=days)
prices_result = await self.db.execute(
select(PriceHistory)
.where(PriceHistory.normalized_product_id == product_id)
.where(
PriceHistory.normalized_product_id == product_id,
PriceHistory.observed_date >= date_threshold,
)
.options(selectinload(PriceHistory.store))
.order_by(PriceHistory.observed_date)
)
@@ -45,20 +50,25 @@ class PublicService:
],
}
async def get_store_comparison(self, product_ids: list[UUID]) -> dict:
async def get_store_comparison(
self, product_ids: list[UUID], category: str | None = None
) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
if not product_ids:
return {"products": []}
# Fetch all products in one query
prod_result = await self.db.execute(
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
)
product_query = select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
if category:
product_query = product_query.where(NormalizedProduct.category == category)
prod_result = await self.db.execute(product_query)
products_by_id = {p.id: p for p in prod_result.scalars().all()}
# Latest prices for all requested products in one query
subq = latest_price_per_store(product_ids)
if not products_by_id:
return {"products": []}
filtered_product_ids = list(products_by_id.keys())
subq = latest_price_per_store(filtered_product_ids)
prices_result = await self.db.execute(
select(PriceHistory)
.join(
@@ -69,18 +79,17 @@ class PublicService:
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
),
)
.where(PriceHistory.normalized_product_id.in_(product_ids))
.where(PriceHistory.normalized_product_id.in_(filtered_product_ids))
.options(selectinload(PriceHistory.store))
)
all_prices = prices_result.scalars().all()
# Group by product
prices_by_product: dict[UUID, list] = {}
for ph in all_prices:
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
products = []
for pid in product_ids:
for pid in filtered_product_ids:
product = products_by_id.get(pid)
if not product:
continue
@@ -102,19 +111,29 @@ class PublicService:
return {"products": products}
async def get_inflation(self) -> dict:
async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict:
"""Aggregate price change stats. Compares average prices across periods."""
from cartsnitch_api.models import NormalizedProduct, PriceHistory
# Get average prices grouped by category for recent vs older data
result = await self.db.execute(
select(
NormalizedProduct.category,
func.avg(PriceHistory.regular_price),
)
.join(NormalizedProduct)
.group_by(NormalizedProduct.category)
)
date_threshold = None
if period != "all-time":
days_map = {"1y": 365, "6m": 180, "3m": 90, "1m": 30}
days = days_map.get(period, 365)
date_threshold = date.today() - timedelta(days=days)
query = select(
NormalizedProduct.category,
func.avg(PriceHistory.regular_price),
).join(NormalizedProduct)
if category:
query = query.where(NormalizedProduct.category == category)
if date_threshold:
query = query.where(PriceHistory.observed_date >= date_threshold)
query = query.group_by(NormalizedProduct.category)
result = await self.db.execute(query)
categories = {}
for row in result.all():
cat, avg_price = row
@@ -122,7 +141,7 @@ class PublicService:
categories[cat] = float(avg_price) if avg_price else 0.0
return {
"period": "all-time",
"period": period,
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
"cpi_baseline": 100.0,
"categories": categories,
+34 -7
View File
@@ -19,6 +19,25 @@ from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app
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:"
@@ -60,7 +79,8 @@ async def db_engine():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute(text("""
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
@@ -71,8 +91,10 @@ async def db_engine():
created_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 (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -88,8 +110,10 @@ async def db_engine():
created_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 (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
@@ -98,7 +122,8 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
""")
)
yield engine
@@ -133,7 +158,9 @@ async def client(db_engine):
app.dependency_overrides.clear()
async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
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 stores the raw token
+154 -18
View File
@@ -1,47 +1,184 @@
"""Tests for rate limiting middleware."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
from cartsnitch_api.config import settings
from cartsnitch_api.middleware.rate_limit import (
InMemorySlidingWindow,
RedisSlidingWindow,
_get_client_ip,
_get_rate_limit_key,
)
class TestSlidingWindowCounter:
class TestInMemorySlidingWindow:
def test_allows_within_limit(self):
counter = _SlidingWindowCounter(max_requests=5, window_seconds=60)
limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60)
for i in range(5):
allowed, remaining, retry = counter.is_allowed("test-key")
allowed, remaining, retry = limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 4 - i
def test_blocks_over_limit(self):
counter = _SlidingWindowCounter(max_requests=3, window_seconds=60)
limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60)
for _ in range(3):
counter.is_allowed("test-key")
limiter.is_allowed("test-key")
allowed, remaining, retry = counter.is_allowed("test-key")
allowed, remaining, retry = limiter.is_allowed("test-key")
assert allowed is False
assert remaining == 0
assert retry > 0
def test_separate_keys(self):
counter = _SlidingWindowCounter(max_requests=2, window_seconds=60)
# Fill key-a
counter.is_allowed("key-a")
counter.is_allowed("key-a")
allowed_a, _, _ = counter.is_allowed("key-a")
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=60)
limiter.is_allowed("key-a")
limiter.is_allowed("key-a")
allowed_a, _, _ = limiter.is_allowed("key-a")
assert allowed_a is False
# key-b should still be allowed
allowed_b, remaining, _ = counter.is_allowed("key-b")
allowed_b, remaining, _ = limiter.is_allowed("key-b")
assert allowed_b is True
assert remaining == 1
def test_resets_after_window_expires(self):
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=1)
for _ in range(2):
limiter.is_allowed("test-key")
allowed, remaining, _ = limiter.is_allowed("test-key")
assert allowed is False
time.sleep(1.1)
allowed, remaining, _ = limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 1
class TestGetClientIp:
def test_x_forwarded_for_single(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_x_forwarded_for_multiple(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_x_forwarded_for_with_port(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1:8080"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_no_forwarded_header(self):
req = MagicMock()
req.headers = {}
req.client.host = "127.0.0.1"
assert _get_client_ip(req) == "127.0.0.1"
def test_no_client(self):
req = MagicMock()
req.headers = {}
req.client = None
assert _get_client_ip(req) == "unknown"
class TestGetRateLimitKey:
def _make_request(
self,
path: str = "/purchases",
method: str = "GET",
auth_header: str = "",
headers: dict | None = None,
) -> MagicMock:
req = MagicMock()
req.url.path = path
req.method = method
req.headers = dict(headers) if headers else {}
if auth_header:
req.headers["authorization"] = auth_header
return req
def test_public_path_uses_public_limiter(self):
req = self._make_request("/public/inflation")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_requests
def test_auth_post_path_uses_strict_limiter(self):
req = self._make_request("/auth/login", method="POST")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_auth_requests
assert limiter.window_seconds == settings.rate_limit_auth_window_seconds
def test_auth_get_path_uses_auth_limiter(self):
req = self._make_request("/auth/me", method="GET")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_requests * 5
def test_authenticated_token_uses_auth_limiter(self):
req = self._make_request("/purchases", auth_header="Bearer token123")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("token:")
assert limiter.max_requests == settings.rate_limit_requests * 5
def test_distinct_tokens_produce_distinct_keys(self):
req1 = self._make_request("/purchases", auth_header="Bearer token_alpha_12345")
req2 = self._make_request("/purchases", auth_header="Bearer token_beta_67890")
key1, _ = _get_rate_limit_key(req1)
key2, _ = _get_rate_limit_key(req2)
assert key1 != key2
def test_same_token_produces_same_key(self):
req1 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
req2 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
key1, _ = _get_rate_limit_key(req1)
key2, _ = _get_rate_limit_key(req2)
assert key1 == key2
def test_key_does_not_contain_raw_token_suffix(self):
raw_token = "my_secret_jwt_token_xyz"
req = self._make_request("/purchases", auth_header=f"Bearer {raw_token}")
key, _ = _get_rate_limit_key(req)
assert raw_token[-16:] not in key
assert raw_token not in key
class TestRedisSlidingWindowFallback:
@pytest.mark.asyncio
async def test_fallback_on_redis_connection_error(self):
mock_redis = AsyncMock()
mock_redis.pipeline.return_value = AsyncMock()
pipe_mock = AsyncMock()
pipe_mock.execute.side_effect = Exception("Connection refused")
mock_redis.pipeline.return_value = pipe_mock
limiter = RedisSlidingWindow(mock_redis, max_requests=5, window_seconds=60)
allowed, remaining, retry = await limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 4
@pytest.mark.asyncio
async def test_fallback_on_redis_error_during_pipeline(self):
mock_redis = AsyncMock()
pipe_mock = AsyncMock()
pipe_mock.execute.side_effect = Exception("Redis error")
mock_redis.pipeline.return_value = pipe_mock
limiter = RedisSlidingWindow(mock_redis, max_requests=3, window_seconds=60)
allowed, remaining, retry = await limiter.is_allowed("test-key")
assert allowed is True
@pytest.mark.asyncio
async def test_rate_limit_returns_429(client):
"""Public endpoint should return 429 after limit exceeded."""
# The default limit is 60/min — we won't hit it in normal tests,
# but we verify the middleware adds rate limit headers.
resp = await client.get("/public/inflation")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
@@ -49,7 +186,6 @@ async def test_rate_limit_returns_429(client):
@pytest.mark.asyncio
async def test_health_skips_rate_limit(client):
"""Health endpoint should not have rate limit headers."""
resp = await client.get("/health")
assert resp.status_code == 200
assert "x-ratelimit-limit" not in resp.headers
+94
View File
@@ -71,3 +71,97 @@ async def test_public_inflation(client, public_data):
data = resp.json()
assert "categories" in data
assert "cartsnitch_index" in data
@pytest.mark.asyncio
async def test_trend_invalid_uuid(client):
resp = await client.get("/public/trends/not-a-uuid")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_zero(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=0")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_negative(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=-1")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_over_max(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=999")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_valid(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=30")
assert resp.status_code == 200
assert "product_name" in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_empty_list(client):
resp = await client.get("/public/store-comparison")
assert resp.status_code == 400
assert "detail" in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_category_xss(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(
f"/public/store-comparison?product_ids={pid}&category=<script>alert(1)</script>"
)
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_category_sql_injection(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/store-comparison?product_ids={pid}&category='; DROP TABLE--")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_inflation_invalid_period(client, public_data):
resp = await client.get("/public/inflation?period=10years")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_inflation_valid_periods(client, public_data):
for period in ["all-time", "1y", "6m", "3m", "1m"]:
resp = await client.get(f"/public/inflation?period={period}")
assert resp.status_code == 200, f"period={period} failed"
@pytest.mark.asyncio
async def test_inflation_category_too_long(client, public_data):
long_category = "x" * 200
resp = await client.get(f"/public/inflation?category={long_category}")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
+4
View File
@@ -9,3 +9,7 @@ DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch
# Port the auth service listens on
PORT=3001
# Resend email provider for transactional email
RESEND_API_KEY=re_your_api_key_here
FROM_EMAIL=CartSnitch <noreply@cartsnitch.com>
+2
View File
@@ -1,4 +1,5 @@
FROM node:22-alpine AS builder
RUN apk update && apk upgrade --no-cache
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
@@ -7,6 +8,7 @@ COPY src/ src/
RUN npm run build
FROM node:22-alpine
RUN apk update && apk upgrade --no-cache
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
+79 -599
View File
@@ -8,12 +8,13 @@
"name": "@cartsnitch/auth",
"version": "0.1.0",
"dependencies": {
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"better-auth": "^1.2.0",
"pg": "^8.13.0"
"pg": "^8.13.0",
"resend": "^6.11.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/bcrypt": "^6.0.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
@@ -590,26 +591,6 @@
"node": ">=18"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@@ -653,6 +634,12 @@
"node": ">=14"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -660,9 +647,9 @@
"license": "MIT"
},
"node_modules/@types/bcrypt": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -691,71 +678,18 @@
"pg-types": "^2.2.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"node-addon-api": "^5.0.0"
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 10.0.0"
"node": ">= 18"
}
},
"node_modules/better-auth": {
@@ -883,90 +817,12 @@
}
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -1009,35 +865,11 @@
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -1054,27 +886,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
@@ -1088,72 +899,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
@@ -1172,94 +917,6 @@
"node": ">=20.0.0"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanostores": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
@@ -1276,84 +933,23 @@
}
},
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"dependencies": {
"abbrev": "1"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/pg": {
@@ -1445,6 +1041,12 @@
"split2": "^4.1.0"
}
},
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1484,18 +1086,25 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"node_modules/resend": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.11.0.tgz",
"integrity": "sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
"postal-mime": "2.7.4",
"svix": "1.90.0"
},
"engines": {
"node": ">= 6"
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve-pkg-maps": {
@@ -1508,78 +1117,18 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -1589,65 +1138,26 @@
"node": ">= 10.x"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"node_modules/svix": {
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
"integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -1689,43 +1199,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1735,12 +1221,6 @@
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+4 -3
View File
@@ -10,15 +10,16 @@
"generate": "npx @better-auth/cli generate"
},
"dependencies": {
"bcrypt": "^6.0.0",
"better-auth": "^1.2.0",
"pg": "^8.13.0",
"bcrypt": "^5.1.1"
"resend": "^6.11.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/bcrypt": "^5.0.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
}
+31 -8
View File
@@ -1,20 +1,30 @@
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
import pg from "pg";
import { Resend } from "resend";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ??
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.warn(
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
"Set DATABASE_URL for production deployments."
);
}
export const pool = new Pool({
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const resend = new Resend(process.env.RESEND_API_KEY);
const fromEmail = process.env.FROM_EMAIL || "CartSnitch <noreply@cartsnitch.com>";
export const auth = betterAuth({
database: pool,
basePath: "/auth",
@@ -27,7 +37,7 @@ export const auth = betterAuth({
maxPasswordLength: 128,
password: {
hash: async (password: string) => {
return bcrypt.hash(password, 10);
return bcrypt.hash(password, 12);
},
verify: async (data: { hash: string; password: string }) => {
return bcrypt.compare(data.password, data.hash);
@@ -35,6 +45,19 @@ export const auth = betterAuth({
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: fromEmail,
to: user.email,
subject: "Verify your CartSnitch email",
html: `<p>Hi ${user.name || ""},</p><p>Click the link below to verify your email address:</p><p><a href="${url}">Verify Email</a></p><p>This link expires in 1 hour.</p><p>— CartSnitch</p>`,
});
},
},
session: {
modelName: "sessions",
fields: {
@@ -97,4 +120,4 @@ export const auth = betterAuth({
"https://cartsnitch.dev.farh.net",
"https://cartsnitch.uat.farh.net",
],
});
});
+17 -3
View File
@@ -1,6 +1,6 @@
import { createServer } from "node:http";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth.js";
import { auth, pool } from "./auth.js";
const port = parseInt(process.env.PORT ?? "3001", 10);
@@ -9,8 +9,22 @@ const handler = toNodeHandler(auth);
const server = createServer(async (req, res) => {
// Health check
if (req.url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
try {
const client = await pool.connect();
try {
await Promise.race([
client.query("SELECT 1"),
new Promise((_, reject) => setTimeout(() => reject(new Error("DB timeout")), 2000)),
]);
} finally {
client.release();
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", db: "connected" }));
} catch {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
}
return;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Submodule
+1
Submodule cartsnitch added at a53daddb9a
@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING
from sqlalchemy import JSON, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import ProductCategory, SizeUnit
@@ -26,7 +27,9 @@ class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
brand: Mapped[str | None] = mapped_column(String(200))
size: Mapped[str | None] = mapped_column(String(50))
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
upc_variants: Mapped[list[str] | None] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), default=list
)
# Relationships
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
+89 -1
View File
@@ -1,4 +1,4 @@
import { test as base, expect } from "@playwright/test";
import { test as base, expect, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
export const test = base.extend<{ axeCheck: void }>({
@@ -10,3 +10,91 @@ export const test = base.extend<{ axeCheck: void }>({
});
export { expect } from "@playwright/test";
const MOCK_USER_ID = "mock_user_123";
const MOCK_SESSION_ID = "mock_session_456";
async function mockAuthRoutes(page: Page, authenticated = false) {
await page.route(/.*\/auth\/sign-up\/email.*/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: null,
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
});
await page.route(/.*\/auth\/sign-in\/email.*/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
redirect: false,
token: "mock_token_123",
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
});
await page.route(/.*\/auth\/get-session.*/, async (route) => {
if (authenticated) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session: {
id: MOCK_SESSION_ID,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ipAddress: null,
userAgent: null,
},
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
} else {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Unauthorized" }),
});
}
});
}
export async function mockSessionDelayed(page: Page, delayMs = 3000) {
await page.route(/.*\/auth\/get-session.*/, async (route) => {
await new Promise((r) => setTimeout(r, delayMs));
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Unauthorized" }),
});
});
}
export { mockAuthRoutes };
+6 -19
View File
@@ -1,18 +1,18 @@
import { test, expect } from '@playwright/test';
import { mockAuthRoutes } from '../fixtures';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('can register a new account and lands on dashboard', async ({ page }) => {
test('can register a new account and see check your email screen', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
await page.fill('[placeholder="Email"]', uniqueEmail());
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
await expect(page).toHaveURL('http://localhost:5173/');
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible();
});
test('shows validation error when registration fields are empty', async ({ page }) => {
@@ -31,22 +31,9 @@ test.describe('J1: Registration and Login', () => {
});
test('can sign in with credentials and land on dashboard', async ({ page }) => {
// Register first so we have a real account
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
// Sign out by clearing the mock session (reload with no session)
await page.goto('/');
await page.reload();
// Now sign in
await mockAuthRoutes(page, true);
await page.goto('/login');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
await page.fill('[placeholder="Password"]', 'TestPass123!');
await page.click('button[type="submit"]');
+7 -13
View File
@@ -1,9 +1,9 @@
import { test, expect } from '@playwright/test';
import { mockAuthRoutes, mockSessionDelayed } from '../fixtures';
test.describe('J8: Unauthenticated Access', () => {
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
// No session cookie — start fresh
await page.context().clearCookies();
await mockAuthRoutes(page, false);
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
@@ -11,7 +11,7 @@ test.describe('J8: Unauthenticated Access', () => {
});
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await mockAuthRoutes(page, false);
await page.goto('/purchases');
await expect(page).toHaveURL(/\/login/);
@@ -19,7 +19,7 @@ test.describe('J8: Unauthenticated Access', () => {
});
test('redirects /products to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await mockAuthRoutes(page, false);
await page.goto('/products');
await expect(page).toHaveURL(/\/login/);
@@ -27,7 +27,7 @@ test.describe('J8: Unauthenticated Access', () => {
});
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await mockAuthRoutes(page, false);
await page.goto('/coupons');
await expect(page).toHaveURL(/\/login/);
@@ -35,15 +35,9 @@ test.describe('J8: Unauthenticated Access', () => {
});
test('shows loading spinner while auth session is pending', async ({ page }) => {
// Intercept but don't respond — session stays pending
await page.context().clearCookies();
await page.request.fetch('/api/auth/session', {
method: 'GET',
});
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
await mockSessionDelayed(page, 3000);
await page.goto('/purchases');
// Spinner is visible briefly; once resolved, should redirect to login
await expect(page.locator('.animate-spin')).toBeVisible({ timeout: 2000 });
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
import { test, expect } from './fixtures';
import { test, expect, mockAuthRoutes } from './fixtures';
test('app loads', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/');
// Unauthenticated users are redirected to /login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
});
+6
View File
@@ -9,6 +9,12 @@ server {
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;
# 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
location /health {
access_log off;
+103 -31
View File
@@ -38,7 +38,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
"vite": "^6.3.5",
"vite": "^6.4.2",
"vite-plugin-pwa": "^0.21.2",
"vitest": "^3.2.4"
}
@@ -1867,6 +1867,40 @@
"node": ">=18"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -2669,6 +2703,25 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@@ -3659,6 +3712,17 @@
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -4166,33 +4230,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
@@ -9473,6 +9510,14 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -9805,9 +9850,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -10020,6 +10065,33 @@
}
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+1 -1
View File
@@ -43,7 +43,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
"vite": "^6.3.5",
"vite": "^6.4.2",
"vite-plugin-pwa": "^0.21.2",
"vitest": "^3.2.4"
},
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
},
],
webServer: {
command: 'VITE_MOCK_AUTH=true npm run dev',
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
+4 -2
View File
@@ -5,7 +5,8 @@ WORKDIR /app
# build-essential and libpq-dev are needed to compile any C-extension wheels
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
RUN apt-get update && apt-get install -y --no-install-recommends \
ARG APT_CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
@@ -25,7 +26,8 @@ FROM python:3.12-slim AS prod
WORKDIR /app
# Install Playwright system dependencies for Chromium
RUN apt-get update && apt-get install -y --no-install-recommends \
ARG APT_CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
+1 -1
View File
@@ -11,7 +11,7 @@ dependencies = [
"cartsnitch-common>=0.1.0",
"playwright>=1.49,<2.0",
"playwright-stealth>=1.0,<2.0",
"cryptography>=42.0,<44.0",
"cryptography>=46.0,<47.0",
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"beautifulsoup4>=4.12,<5.0",
+34 -1
View File
@@ -1,8 +1,12 @@
"""Service-specific configuration for ReceiptWitness."""
from pydantic import model_validator
from pydantic_settings import BaseSettings
_PLACEHOLDER_VALUES = {"change-me-in-production"}
class ReceiptWitnessSettings(BaseSettings):
model_config = {"env_prefix": "RW_"}
@@ -30,5 +34,34 @@ class ReceiptWitnessSettings(BaseSettings):
# Mailgun inbound email webhook
mailgun_webhook_signing_key: str = ""
@model_validator(mode="after")
def validate_required_vars(self):
errors = []
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
errors.append(
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
if self.notifications_enabled and not self.resend_api_key:
errors.append(
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
"Get an API key from https://resend.com/api-keys"
)
if errors:
raise ValueError(
"ReceiptWitness startup failed — missing required config:\n"
+ "\n".join(f" - {e}" for e in errors)
)
return self
settings = ReceiptWitnessSettings()
class _LazySettings:
_instance: ReceiptWitnessSettings | None = None
def __getattr__(self, name: str):
if _LazySettings._instance is None:
_LazySettings._instance = ReceiptWitnessSettings()
return getattr(_LazySettings._instance, name)
settings = _LazySettings()
@@ -5,12 +5,14 @@ Matches products across retailers by:
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
"""
import json
import re
from dataclasses import dataclass
from enum import StrEnum
from cartsnitch_common.models.product import NormalizedProduct
from sqlalchemy import select
from sqlalchemy import cast, func, select, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session
@@ -96,17 +98,24 @@ def jaccard_similarity(a: str, b: str) -> float:
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
"""Find a normalized product by exact UPC match.
Loads products with upc_variants and checks membership in Python
for cross-database compatibility (works on both PostgreSQL and SQLite).
Uses PostgreSQL JSONB containment (@>) for production efficiency.
Falls back to LIKE on SQLite for test compatibility.
"""
# TODO: Use PostgreSQL JSON containment query (@>) for production.
# Current approach loads all products into memory — acceptable for tests
# and small datasets, but will not scale.
stmt = select(NormalizedProduct).where(NormalizedProduct.upc_variants.is_not(None))
products = session.execute(stmt).scalars().all()
for product in products:
if product.upc_variants and upc in product.upc_variants:
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
dialect_name = session.bind.dialect.name if session.bind else "default"
if dialect_name == "postgresql":
stmt = select(NormalizedProduct).where(
cast(NormalizedProduct.upc_variants, JSONB).op("@>")(
func.cast(json.dumps([upc]), JSONB)
)
)
else:
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
+4
View File
@@ -1,12 +1,16 @@
"""Shared test fixtures."""
import json
import os
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).parent / "fixtures"
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
@pytest.fixture
def meijer_receipt_data() -> dict:
+46
View File
@@ -0,0 +1,46 @@
import pytest
from receiptwitness.config import ReceiptWitnessSettings
def test_valid_config():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
)
assert s.session_encryption_key
def test_missing_session_encryption_key_raises():
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
ReceiptWitnessSettings(session_encryption_key="")
def test_placeholder_session_encryption_key_raises():
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
def test_notifications_enabled_without_resend_key_raises():
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=True,
resend_api_key="",
)
def test_notifications_disabled_without_resend_key_ok():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=False,
resend_api_key="",
)
assert s.notifications_enabled is False
def test_notifications_enabled_with_resend_key_ok():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=True,
resend_api_key="re_test_1234567890",
)
assert s.resend_api_key == "re_test_1234567890"
+2
View File
@@ -15,6 +15,7 @@ import { AccountLinking } from './pages/AccountLinking.tsx'
import { Login } from './pages/Login.tsx'
import { Register } from './pages/Register.tsx'
import { ForgotPassword } from './pages/ForgotPassword.tsx'
import { VerifyEmail } from './pages/VerifyEmail.tsx'
const queryClient = new QueryClient({
defaultOptions: {
@@ -47,6 +48,7 @@ export default function App() {
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route path="forgot-password" element={<ForgotPassword />} />
<Route path="verify-email" element={<VerifyEmail />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
-17
View File
@@ -1,25 +1,8 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() {
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
useEffect(() => {
if (!isMockAuth) {
setAuthenticated(!!session)
}
}, [session, setAuthenticated, isMockAuth])
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
if (isMockAuth) {
if (!isAuthenticated) return <Navigate to="/login" replace />
return <Outlet />
}
if (isPending) {
return (
+3 -3
View File
@@ -79,21 +79,21 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">Watching</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<p className="text-xs text-gray-400">price alerts</p>
<p className="text-xs text-gray-600">price alerts</p>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">This Month</p>
<p className="mt-1 text-2xl font-bold text-gray-900">
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
</p>
<p className="text-xs text-gray-400">grocery spend</p>
<p className="text-xs text-gray-600">grocery spend</p>
</div>
</div>
{/* Price trend sparklines */}
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<div className="rounded-xl bg-white p-4 shadow-sm text-center text-sm text-gray-400">
<div className="rounded-xl bg-white p-4 shadow-sm text-center text-sm text-gray-600">
Connect a store to see price trends
</div>
</section>
+7 -7
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
@@ -8,7 +8,6 @@ export function Login() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) {
@@ -31,18 +30,19 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed')
}
// After successful signIn, force a session fetch to confirm the cookie is set
// before navigating to the protected route
// After successful signIn, force a full page reload so Better-Auth's
// useSession() reinitializes with fresh cookie-backed session state.
// Using React Router's navigate() races with Better-Auth's internal update.
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
window.location.href = '/'
} else {
setError('Sign in failed. Please try again.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
navigate('/')
window.location.href = '/'
} else {
setError('Invalid email or password. Please try again.')
}
@@ -100,4 +100,4 @@ export function Login() {
</p>
</main>
)
}
}
+49 -19
View File
@@ -1,7 +1,6 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function Register() {
const [name, setName] = useState('')
@@ -9,8 +8,9 @@ export function Register() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const [registrationComplete, setRegistrationComplete] = useState(false)
const [resendLoading, setResendLoading] = useState(false)
const [resendMessage, setResendMessage] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -38,27 +38,57 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed')
}
// 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.')
}
setRegistrationComplete(true)
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
navigate('/')
} else {
setError('Registration failed. Please try again.')
}
setError('Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
async function handleResendVerification() {
setResendLoading(true)
setResendMessage('')
try {
const { error } = await authClient.sendVerificationEmail({ email })
if (error) {
setResendMessage('Failed to resend. Please try again.')
} else {
setResendMessage('Verification email sent!')
}
} finally {
setResendLoading(false)
}
}
if (registrationComplete) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Check your email</h1>
<p className="mb-8 text-sm text-gray-500">
We sent a verification link to {email}. Click it to activate your account.
</p>
<button
type="button"
onClick={handleResendVerification}
disabled={resendLoading}
className="min-h-12 rounded-xl bg-brand-blue px-6 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{resendLoading ? 'Sending...' : 'Resend email'}
</button>
{resendMessage && (
<p className="mt-4 text-sm text-gray-500">{resendMessage}</p>
)}
<p className="mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-brand-blue">
Sign in
</Link>
</p>
</div>
)
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { authClient } from "../lib/auth-client.ts";
export function VerifyEmail() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying");
const [resendEmail, setResendEmail] = useState("");
const [showResend, setShowResend] = useState(false);
const [resending, setResending] = useState(false);
const [resendMessage, setResendMessage] = useState("");
useEffect(() => {
const token = searchParams.get("token");
const callbackURL = searchParams.get("callbackURL") || "/";
if (!token) {
setStatus("error");
return;
}
authClient.verifyEmail({ query: { token } })
.then(() => {
setStatus("success");
setTimeout(() => {
navigate(callbackURL);
}, 2000);
})
.catch(() => {
setStatus("error");
});
}, [searchParams, navigate]);
async function handleResend() {
if (!resendEmail) {
setResendMessage("Please enter your email address.");
return;
}
setResending(true);
setResendMessage("");
try {
const { error } = await authClient.sendVerificationEmail({ email: resendEmail });
if (error) {
setResendMessage("Failed to resend. Please try again.");
} else {
setResendMessage("Verification email sent!");
setShowResend(false);
}
} finally {
setResending(false);
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
{status === "verifying" && (
<>
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-blue" />
<h1 className="mb-2 text-2xl font-bold text-gray-900">Verifying your email...</h1>
<p className="text-sm text-gray-500">Please wait while we verify your email address.</p>
</>
)}
{status === "success" && (
<>
<h1 className="mb-2 text-2xl font-bold text-gray-900">Email verified!</h1>
<p className="text-sm text-gray-500">Redirecting you shortly...</p>
</>
)}
{status === "error" && (
<>
<h1 className="mb-2 text-2xl font-bold text-gray-900">Verification failed</h1>
<p className="mb-6 text-sm text-gray-500">The verification link may have expired or is invalid.</p>
{!showResend ? (
<button
type="button"
onClick={() => setShowResend(true)}
className="min-h-12 rounded-xl bg-brand-blue px-6 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Resend verification email
</button>
) : (
<div className="w-full max-w-sm space-y-4">
<input
type="email"
placeholder="Your email address"
value={resendEmail}
onChange={(e) => setResendEmail(e.target.value)}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{resending ? "Sending..." : "Send verification email"}
</button>
{resendMessage && (
<p className="text-sm text-gray-500">{resendMessage}</p>
)}
</div>
)}
</>
)}
</div>
);
}