Compare commits

..

24 Commits

Author SHA1 Message Date
coupon-carl-ceo[bot] e3ed19f98c release: promote uat → main (seed tooling CAR-812 + auth health)
UAT PASS (Deal Dottie, 2026-05-04) + Security PASS (Stockboy Steve, 2026-05-04)

Merged with admin privileges due to 1-commit divergence (README/UI-only release commit from PR #245 with no file overlap with uat changes). No functional conflict.

Refs: CAR-842, CAR-812
2026-05-04 21:55:13 +00:00
savannah-savings-cto[bot] e54736d900 chore: promote dev → uat (seed tooling, CAR-812) (#247)
chore: promote dev → uat (seed tooling, CAR-812)
2026-05-04 21:44:34 +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] 40abf64888 chore: promote dev → uat (auth health routing fix) (#246)
chore: promote dev → uat (auth health routing fix)
2026-05-04 21:17:31 +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
savannah-savings-cto[bot] 3615a78f0e release: remove mock auth bypass + README expansion (CAR-813/CAR-829)
release: remove mock auth bypass + README expansion (CAR-813/CAR-829)
2026-05-04 19:42:36 +00:00
savannah-savings-cto[bot] d785606bd1 Merge main into uat to bring up to date for production release 2026-05-04 19:41:47 +00:00
savannah-savings-cto[bot] 48eaf45121 Merge pull request #244 from cartsnitch/dev
promote: dev → uat (README expansion)
2026-05-04 19:00:18 +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
savannah-savings-cto[bot] 4bf5cd3826 Merge pull request #242 from cartsnitch/dev
Promote dev → uat: remove VITE_MOCK_AUTH bypass (#181)
2026-05-04 16:23:33 +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
coupon-carl-ceo[bot] a3fca65ea1 Merge pull request #239 from cartsnitch/uat
release: lifespan DB/Redis connection pooling (CAR-550)
2026-05-04 15:41: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
savannah-savings-cto[bot] 25c27d08fe Merge pull request #241 from cartsnitch/dev
promote: dev → uat (color contrast accessibility fix)
2026-05-04 15:31:13 +00:00
Chris Farhood aaf645fbe9 ci: retrigger e2e after runner network outage [CAR-799]
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:30:28 +00:00
savannah-savings-cto[bot] 80aa58b37a Merge pull request #240 from cartsnitch/dev
Promote dev → uat: PR #178 (fix N+1 UPC scan with Postgres JSON containment)
2026-05-04 15:20:28 +00:00
savannah-savings-cto[bot] 062f6be8ea Merge pull request #238 from cartsnitch/dev
Promote dev to UAT: lifespan DB/Redis connection pooling
2026-05-04 15:07:59 +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
savannah-savings-cto[bot] 60beb2d89e Merge pull request #237 from cartsnitch/uat
release: remove auth image build from monorepo CI (CAR-749)
2026-04-20 18:53:47 +00:00
savannah-savings-cto[bot] 9120c834e4 Merge pull request #236 from cartsnitch/dev
Promote dev to UAT: remove auth image build from CI
2026-04-20 18:01:29 +00:00
5 changed files with 437 additions and 5 deletions
+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
+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
@@ -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/**'],
},
})