Compare commits

...

21 Commits

Author SHA1 Message Date
Barcode Betty e807ca4c65 fix: remove dead dispose_engine import from API main.py
CI / test (pull_request) Failing after 1m32s
CI / lighthouse (pull_request) Has been skipped
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-api (pull_request) Has been skipped
CI / e2e (pull_request) Failing after 1m34s
CI / build-and-push (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m29s
CI / audit (pull_request) Failing after 1m25s
CI / build-and-push-auth (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
The dispose_engine import was causing ImportError at module load in UAT
because database.py was refactored to use init_db/close_db lifecycle
functions. The lifespan function already imports init_db and close_db
locally. Fixes CAR-953.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 02:58:51 +00:00
Savannah Savings 4797f07af9 Merge pull request 'ci: move .github/workflows to .gitea/workflows [CAR-900]' (#259) from barcode-betty/move-workflows-to-gitea into dev
CI / build-and-push-api (push) Failing after 7s
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-auth (pull_request) Has been skipped
CI / lint (push) Successful in 13s
CI / test (pull_request) Successful in 11s
CI / lint (pull_request) Successful in 13s
CI / e2e (pull_request) Successful in 41s
CI / e2e (push) Successful in 38s
CI / deploy-dev (pull_request) Has been skipped
CI / test (push) Successful in 12s
CI / audit (push) Failing after 11s
CI / audit (pull_request) Failing after 11s
CI / build-and-push-receiptwitness (push) Failing after 7s
CI / build-and-push-auth (push) Failing after 7s
CI / build-and-push-api (pull_request) Has been skipped
CI / lighthouse (push) Failing after 1m17s
CI / build-and-push (pull_request) Has been skipped
CI / build-and-push (push) Failing after 11s
CI / deploy-dev (push) Failing after 34s
CI / lighthouse (pull_request) Failing after 1m13s
CI / deploy-uat (push) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Merge PR #259: ci: move .github/workflows to .gitea/workflows [CAR-897]

QA-approved. CTO merge to dev.

cc @cpfarhood
2026-05-21 19:19:20 +00:00
Flea Flicker 96331c9fa7 Move .github/workflows to .gitea/workflows
CI / test (pull_request) Successful in 13s
CI / lint (pull_request) Successful in 14s
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-api (pull_request) Has been skipped
CI / build-and-push-auth (pull_request) Has been skipped
CI / audit (pull_request) Failing after 10s
CI / e2e (pull_request) Successful in 39s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lighthouse (pull_request) Failing after 1m12s
- Relocate all CI workflows from .github/workflows/ to .gitea/workflows/
- Root: .github/workflows/ci.yml -> .gitea/workflows/ci.yml
- api/: api/.github/workflows/ci.yml -> api/.gitea/workflows/ci.yml
- common/: common/.github/workflows/ci.yml -> common/.gitea/workflows/ci.yml
- Gitea uses .gitea/workflows/ for CI configuration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 11:59:35 +00:00
Savannah Savings a4e0b664e1 Merge pull request 'fix: use GHCR_USERNAME and GHCR_TOKEN for GHCR login in Gitea Actions' (#258) from betty/fix-gitea-ci-secrets into dev
CI / audit (push) Failing after 11s
CI / test (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / build-and-push-receiptwitness (push) Failing after 8s
CI / build-and-push-api (push) Failing after 8s
CI / build-and-push-auth (push) Failing after 8s
CI / e2e (push) Successful in 39s
CI / build-and-push (push) Failing after 8s
CI / deploy-uat (push) Has been skipped
CI / lighthouse (push) Failing after 1m16s
CI / deploy-dev (push) Failing after 43s
2026-05-21 06:21:55 +00:00
Flea Flicker f4bbddd0dd fix: use GHCR_USERNAME and GHCR_TOKEN for GHCR login in Gitea Actions
CI / audit (pull_request) Failing after 11s
CI / test (pull_request) Successful in 13s
CI / lint (pull_request) Successful in 14s
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-api (pull_request) Has been skipped
CI / build-and-push-auth (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 40s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lighthouse (pull_request) Failing after 1m11s
Gitea's GITHUB_TOKEN authenticates against git.farh.net, not ghcr.io.
Use explicit GHCR_USERNAME and GHCR_TOKEN secrets instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 05:25:36 +00:00
Savannah Savings 5a97290356 Merge pull request 'ci: convert GitHub Actions to Gitea Actions (ubuntu-latest)' (#253) from betty/car-869-gitea-actions-cartsnitch into dev
CI / e2e (push) Successful in 41s
CI / lighthouse (push) Failing after 1m15s
CI / test (pull_request) Successful in 11s
CI / build-and-push-auth (push) Failing after 2m6s
CI / deploy-uat (push) Has been skipped
CI / audit (pull_request) Failing after 10s
CI / build-and-push-api (pull_request) Has been skipped
CI / build-and-push-receiptwitness (push) Failing after 2m3s
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-auth (pull_request) Has been skipped
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / build-and-push-api (push) Failing after 2m4s
CI / audit (push) Failing after 10s
CI / test (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / lint (pull_request) Successful in 13s
CI / build-and-push (push) Failing after 6s
CI / deploy-uat (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 40s
CI / deploy-dev (push) Failing after 36s
CI / lighthouse (pull_request) Failing after 1m17s
2026-05-21 04:22:43 +00:00
Flea Flicker 32495b150b ci: convert GitHub Actions to Gitea Actions (ubuntu-latest)
CI / audit (pull_request) Failing after 11s
CI / test (pull_request) Successful in 13s
CI / lint (pull_request) Successful in 14s
CI / build-and-push-receiptwitness (pull_request) Has been skipped
CI / build-and-push-api (pull_request) Has been skipped
CI / build-and-push-auth (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 40s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lighthouse (pull_request) Failing after 1m13s
- Replace all runs-on: runners-cartsnitch with ubuntu-latest
- Remove SARIF upload steps (no Gitea Security tab)
- Replace GitHub App token with secrets.GITEA_TOKEN in deploy-dev and deploy-uat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 03:54:18 +00:00
savannah-savings-cto[bot] b39280ee2a fix(auth): exclude test files from tsc build (#251)
fix(auth): exclude test files from tsc build
2026-05-05 11:19:19 +00:00
Chris Farhood 752d7ed3d0 fix(auth): exclude test files from tsc compilation
Exclude src/__tests__ from tsconfig to prevent test files from being
compiled during Docker build. Fixes build-and-push-auth CI failure.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 11:11:53 +00:00
savannah-savings-cto[bot] 1f317a0616 Merge pull request #249 from cartsnitch/betty/car-843-auth-ci-pipeline
ci: add auth service build/deploy to CI pipeline
2026-05-05 10:56:19 +00:00
Chris Farhood 912239a97b ci: add auth service build/deploy to CI pipeline
Add build-and-push-auth job and update deploy-dev/uat to include auth image.

- Add AUTH_IMAGE_NAME env var
- Add build-and-push-auth job (modeled on build-and-push-api)
- Add build-and-push-auth to deploy-dev and deploy-uat needs
- Add auth image tag determination and update steps in both deploy jobs
- Update commit messages to include auth
2026-05-05 06:44:15 +00:00
savannah-savings-cto[bot] 59850c0cb4 feat: parameterize seed tooling for UAT + document UAT receipt-submission path (#243)
feat: parameterize seed tooling for UAT + document UAT receipt-submission path
2026-05-04 21:43:56 +00:00
savannah-savings-cto[bot] 4e72e61f6d fix(auth): add DB connectivity check to health endpoint (#184)
fix(auth): add DB connectivity check to health endpoint
2026-05-04 21:16:52 +00:00
Chris Farhood 04965eb89d fix(auth): restore unconditional Better-Auth fallback, add unknown-path test
Remove startsWith('/auth') guard that caused non-auth paths to hang with
no response. Better-Auth already handles /health and /auth/health are
explicitly short-circuited before the handler. Add test asserting unknown
paths receive a terminal response within 1s.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 20:58:50 +00:00
cartsnitch-engineer[bot] 48a999d569 docs: expand README with architecture and contribution guide (#190)
* docs: expand README with architecture and contribution guide

* fix: address CTO review feedback on README

* fix: align API Gateway box label padding in ASCII diagram

---------

Co-authored-by: Barcode Betty <barcode-betty@cartsnitch.ing>
Co-authored-by: Barcode Betty <barcode-betty@paperclip.ing>
Co-authored-by: Chris Farhood <chris@farhood.org>
2026-05-04 18:39:37 +00:00
Chris Farhood ea2fddc5cb fix(auth): support /auth/health path and align db response with tests
- Add /auth/health as additional health check route (Envoy forwards full path)
- Change db status 'connected' to 'reachable' to match health.test.ts
- Only pass /auth/* routes to Better-Auth handler to prevent 404 on unknown routes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 16:22:41 +00:00
Chris Farhood 44d9502673 chore: exclude auth tests from root vitest
Auth package has its own test runner (node --test) configured.
Exclude auth directory from root vitest to prevent no-test-suite error.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:51:53 +00:00
Chris Farhood 3ac61908f5 test(auth): add health endpoint unit tests
- Add node:test suite for auth health endpoint covering:
  - 200 with db=reachable when pool.connect succeeds
  - 503 with db=unreachable when pool.connect throws
  - 503 with db=unreachable when query times out
- Add test script to auth/package.json
- Merge dev to resolve 3-commit divergence

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:40:04 +00:00
Chris Farhood 2a7f1921b0 Merge branch 'dev' into betty/car-555-health-check-db 2026-05-04 15:32:28 +00:00
Barcode Betty 8d7e0b44ee fix: restore Resend email verification and update health check timeout
- Restore import { Resend } from 'resend'
- Restore resend and fromEmail constants
- Restore emailVerification block with sendOnSignUp, autoSignInAfterVerification, and sendVerificationEmail
- Change health endpoint timeout from 2s to 3s

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 19:55:47 +00:00
Paperclip 9c7cd7454c fix(auth): add DB connectivity check to health endpoint
- Export pool from auth.ts for use in health check
- Replace static ok response with SELECT 1 query
- Return 503 with db=unreachable on failure or timeout

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 19:49:08 +00:00
10 changed files with 582 additions and 62 deletions
@@ -20,10 +20,11 @@ env:
IMAGE_NAME: cartsnitch/cartsnitch
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
API_IMAGE_NAME: cartsnitch/api
AUTH_IMAGE_NAME: cartsnitch/auth
jobs:
lint:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -37,7 +38,7 @@ jobs:
run: npx tsc --noEmit
test:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -49,7 +50,7 @@ jobs:
run: npx vitest run
audit:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -61,7 +62,7 @@ jobs:
run: npm audit --audit-level=high
e2e:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -73,7 +74,7 @@ jobs:
- run: npx playwright test
lighthouse:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
@@ -98,7 +99,7 @@ jobs:
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
build-and-push:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: [lint, test, e2e]
outputs:
@@ -138,8 +139,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract metadata
id: meta
@@ -174,11 +175,7 @@ jobs:
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'
@@ -198,7 +195,7 @@ jobs:
git push origin "v${{ steps.calver.outputs.version }}"
build-and-push-receiptwitness:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: [lint, test]
outputs:
@@ -232,8 +229,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract metadata
id: meta
@@ -270,11 +267,7 @@ jobs:
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'
@@ -290,7 +283,7 @@ jobs:
cache-from: type=gha
build-and-push-api:
runs-on: runners-cartsnitch
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: [lint, test]
outputs:
@@ -324,8 +317,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract metadata (API)
id: meta
@@ -362,11 +355,7 @@ jobs:
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'
@@ -381,25 +370,104 @@ jobs:
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
build-and-push-auth:
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps:
- uses: actions/checkout@v4
with:
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: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_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 Docker image
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
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 auth image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/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-receiptwitness, build-and-push-api]
runs-on: ubuntu-latest
needs: [build-and-push, build-and-push-receiptwitness, build-and-push-api, build-and-push-auth]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
@@ -454,6 +522,21 @@ jobs:
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_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: Commit and push to infra
run: |
cd infra
@@ -461,29 +544,20 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(dev): update cartsnitch, receiptwitness, and api images"
git commit -m "ci(dev): update cartsnitch, receiptwitness, api, and auth images"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-receiptwitness, build-and-push-api]
runs-on: ubuntu-latest
needs: [build-and-push, build-and-push-receiptwitness, build-and-push-api, build-and-push-auth]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
@@ -538,6 +612,21 @@ jobs:
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_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: Commit and push to infra
run: |
cd infra
@@ -545,6 +634,6 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(uat): update cartsnitch, receiptwitness, and api images"
git commit -m "ci(uat): update cartsnitch, receiptwitness, api, and auth images"
git pull --rebase origin main
git push origin main
+314
View File
@@ -1 +1,315 @@
# CartSnitch
**Grocery price intelligence — know what you're paying, every time.**
CartSnitch is a self-hosted grocery price intelligence platform that connects to your store loyalty accounts, tracks prices across retailers, monitors shrinkflation, and helps you find the best deals.
---
## Project Overview
CartSnitch solves the problem of **grocery price opacity**. Most shoppers don't know if they're getting a good deal, whether prices have spiked since their last visit, or if the "sale" is actually a worse price than a competitor. CartSnitch makes prices transparent.
**Core features:**
- Connect Meijer, Kroger, Target loyalty accounts
- View purchase history across all stores in one timeline
- Track per-item price charts across stores over time
- Receive shrinkflation and price increase alerts
- Browse active coupons and deals
- Generate optimized shopping lists with store-split plans
- Public price transparency dashboards
---
## Architecture
CartSnitch is a polyglot microservices platform. The monorepo contains the frontend PWA and core services.
```
┌─────────────────────────────────────────────────────────────────┐
│ CartSnitch PWA │
│ (React, mobile-first PWA) │
└──────────┬────────────────────┬────────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Auth Service │ │ API Gateway │ │ ReceiptWitness │
│ (Better-Auth) │ │ (Python/FastAPI)│ │ (Python/Scrapers) │
│ Session mgmt │ │ REST + proxy │ │ Purchase ingestion │
└────────┬─────────┘ └────────┬────────┘ └──────────┬──────────┘
│ │ │
└──────────────────────┼────────────────────────┘
┌────────────────────────┐
│ CloudNativePG (PGSQL) │
│ Shared database │
└────────────────────────┘
```
### Services in This Repo
| Directory | Service | Description |
|-----------|---------|-------------|
| `/` (root) | Frontend | React PWA, mobile-first |
| `auth/` | Auth | Better-Auth service — session management, email/password, OAuth |
| `api/` | API Gateway | Frontend-facing REST API, Python/FastAPI |
| `common/` | Common | Shared Python models, Pydantic schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
### Other CartSnitch Repos
| Repo | Service |
|------|---------|
| `cartsnitch/stickershock` | Price increase detection & CPI comparison |
| `cartsnitch/shrinkray` | Shrinkflation monitoring |
| `cartsnitch/clipartist` | Coupon/deal watching |
| `cartsnitch/infra` | Kubernetes manifests, Flux kustomizations |
---
## Tech Stack
### Frontend
- **React 18+** with TypeScript
- **Vite** — build tool
- **Tailwind CSS v4** — mobile-first responsive design
- **Workbox** — service worker, offline caching, PWA manifest
- **Recharts** — price trend visualizations
- **TanStack Query** — data fetching and caching
- **React Router v7** — client-side routing
- **Zustand** — lightweight state management
### Backend Services
- **Better-Auth** — authentication (session management, email/password, OAuth)
- **Node.js** (API Gateway)
- **Python/FastAPI** (API Gateway, ReceiptWitness)
- **PostgreSQL** via CloudNativePG
- **DragonflyDB** for caching
### Infrastructure
- **Kubernetes** (k3s-compatible)
- **Flux CD** — GitOps deployment
- **GitHub Actions** — CI/CD
- **CalVer** (`YYYY.MM.DD[.N]`) — image tagging
- **Bitnami Sealed Secrets** — secret management
- **Authentik** — OIDC/OAuth2 provider
---
## Getting Started
### Prerequisites
- Node.js 20+
- npm or pnpm
- PostgreSQL (local or containerized)
- Docker (for running services locally)
### Local Development
1. **Clone the repo**
```bash
git clone https://github.com/cartsnitch/cartsnitch.git
cd cartsnitch
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp .env.example .env
# Edit .env with your local settings
```
4. **Start the frontend dev server**
```bash
npm run dev
```
The PWA will be available at `http://localhost:5173`.
5. **Run tests**
```bash
npm test
```
6. **Build for production**
```bash
npm run build
```
### Running Backend Services Locally
The frontend PWA communicates with three backend services. For full local development, you'll need to run each service:
```bash
# Auth service (Better-Auth)
cd auth
npm install
npm run dev
# API Gateway (separate repo: cartsnitch/api)
# See api/README.md
# ReceiptWitness (separate repo: cartsnitch/receiptwitness)
# See receiptwitness/README.md
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_API_URL` | API Gateway base URL | `http://localhost:3000` |
| `VITE_AUTH_URL` | Auth service base URL | `http://localhost:3001` |
---
## Contributing
We welcome contributions. Please follow the workflow below.
### Branching Strategy
- Branch from `dev`
- Use prefix: `feature/`, `fix/`, `docs/`, `chore/`
- Examples: `feature/shopping-list-optimization`, `fix/price-chart-zoom`
### Commit Convention
We use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add shopping list export
fix: correct price chart date formatting
docs: update API documentation
chore: update dependencies
```
### Pull Request Workflow
1. Open a PR against `dev`
2. CI must pass (lint, type check, tests, e2e)
3. QA reviews and approves
4. CTO merges to `dev`
5. Dev deploys automatically
6. CTO promotes `dev → uat`
7. UAT and security review
8. CEO merges `uat → main`
9. Production deploys automatically
**Never push directly to `main`, `dev`, or `uat`.**
### Code Standards
- ESLint for linting
- TypeScript strict mode
- Mobile-first responsive design
- Accessibility (WCAG 2.1 AA)
---
## Testing
### Unit Tests
```bash
npm test
```
### E2E Tests (Playwright)
```bash
npm run test:e2e
```
Tests run headless by default. For headed mode:
```bash
npm run test:e2e:headed
```
### Lighthouse CI
Performance audits run automatically in CI. To run locally:
```bash
npm run build
npm run preview
# In another terminal:
npx lighthouse http://localhost:4173 --output=html --output-path=./report/lighthouse.html
```
---
## CI/CD Pipeline
All branches (`main`, `dev`, `uat`) run through GitHub Actions on every push.
### Pipeline Stages
| Job | Trigger | Purpose |
|-----|---------|---------|
| `lint` | Every push | ESLint + TypeScript type check |
| `test` | Every push | Unit tests via Vitest |
| `audit` | Every push | Security vulnerability scan |
| `e2e` | Every push | Playwright end-to-end tests |
| `lighthouse` | After test | Performance budget check |
| `build-and-push` | On push to main/dev/uat | Build and push Docker images to GHCR |
| `deploy-dev` | On push to dev or main | Update `cartsnitch/infra` → auto-deploy to dev |
| `deploy-uat` | On push to uat or main | Update `cartsnitch/infra` → auto-deploy to uat |
### Image Tagging
- **Production (`main`):** CalVer tag (`YYYY.MM.DD[.N]`) + `latest`
- **Development (`dev`):** SHA tag (`sha-<short-sha>`)
### Deployment Environments
| Environment | Namespace | URL | Trigger |
|-------------|-----------|-----|---------|
| Dev | `cartsnitch-dev` | `cartsnitch.dev.farh.net` | Push to `dev` branch |
| UAT | `cartsnitch-uat` | `cartsnitch.uat.farh.net` | Push to `uat` branch |
| Production | `cartsnitch` | `cartsnitch.farh.net` | Push to `main` branch |
---
## Deployment
### Infrastructure
The infrastructure repository ([cartsnitch/infra](https://github.com/cartsnitch/infra)) contains Kubernetes manifests and Flux Kustomize overlays.
### Flux GitOps Flow
1. CI builds and pushes a new Docker image
2. CI opens a PR to `cartsnitch/infra` updating the image tag
3. On merge, Flux reconciles the manifests and rolls out the new image
### Forcing a Rollout
To force pods to pick up a new `:latest` image:
```bash
kubectl rollout restart deployment/<name> -n <namespace>
```
### Secrets
Secrets are managed via **Bitnami Sealed Secrets**. No plain Kubernetes secrets are used.
---
## Related Projects
- [StickerShock](https://github.com/cartsnitch/stickershock) — Price increase detection
- [ShrinkRay](https://github.com/cartsnitch/shrinkray) — Shrinkflation monitoring
- [ClipArtist](https://github.com/cartsnitch/clipartist) — Coupon/deal optimization
- [Infra](https://github.com/cartsnitch/infra) — Kubernetes infrastructure
---
## License
MIT &copy; 2025 CartSnitch
-1
View File
@@ -6,7 +6,6 @@ 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
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"generate": "npx @better-auth/cli generate"
"generate": "npx @better-auth/cli generate",
"test": "node --test src/__tests__/*.test.ts"
},
"dependencies": {
"bcrypt": "^6.0.0",
+117
View File
@@ -0,0 +1,117 @@
import { describe, it } from 'node:test';
import { equal } from 'node:assert';
import http from 'node:http';
describe('Auth health endpoint', () => {
const startHealthServer = (poolMock) => {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
if (req.url === '/health' && req.method === 'GET') {
try {
const client = await poolMock.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: 'reachable' }));
} catch {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'error', db: 'unreachable' }));
}
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, '0.0.0.0', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
resolve({ port, close: () => server.close() });
});
});
};
const makeRequest = (port) => {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}/health`, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body });
});
});
req.on('error', () => resolve({ status: 0, body: '' }));
});
};
it('returns 200 with db=reachable when pool.connect succeeds', async () => {
const mockClient = {
query: async () => ({ rows: [{ 1: 1 }] }),
release: () => {},
};
const poolMock = {
connect: async () => mockClient,
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 200);
equal(body, '{"status":"ok","db":"reachable"}');
});
it('returns 503 with db=unreachable when pool.connect throws', async () => {
const poolMock = {
connect: async () => { throw new Error('connection refused'); },
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 503);
equal(body, '{"status":"error","db":"unreachable"}');
});
it('returns 503 with db=unreachable when query times out', async () => {
const mockClient = {
query: async () => {
await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000));
},
release: () => {},
};
const poolMock = {
connect: async () => mockClient,
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 503);
equal(body, '{"status":"error","db":"unreachable"}');
});
it('returns a terminal response for unknown paths (no hang)', async () => {
const poolMock = { connect: async () => ({ query: async () => {}, release: () => {} }) };
const { port, close } = await startHealthServer(poolMock);
const result = await new Promise<{ status: number }>((resolve) => {
const req = http.get(`http://localhost:${port}/`, (res) => {
res.resume();
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
});
req.on('error', () => resolve({ status: 0 }));
setTimeout(() => resolve({ status: -1 }), 1000);
});
close();
equal(result.status !== -1, true, 'Unknown path must return a terminal response within 1s');
});
});
+3 -3
View File
@@ -8,7 +8,7 @@ const handler = toNodeHandler(auth);
const server = createServer(async (req, res) => {
// Health check
if (req.url === "/health" && req.method === "GET") {
if ((req.url === "/health" || req.url === "/auth/health") && req.method === "GET") {
try {
const client = await pool.connect();
try {
@@ -20,7 +20,7 @@ const server = createServer(async (req, res) => {
client.release();
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", db: "connected" }));
res.end(JSON.stringify({ status: "ok", db: "reachable" }));
} catch {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
@@ -28,7 +28,7 @@ const server = createServer(async (req, res) => {
return;
}
// All /auth/* routes handled by Better-Auth
// All other routes handled by Better-Auth (returns 404 for unknown paths)
await handler(req, res);
});
+1 -1
View File
@@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/__tests__"]
}
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
exclude: ['e2e/**', 'auth/**', 'node_modules/**'],
},
})