forked from cartsnitch/cartsnitch
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 752d7ed3d0 | |||
| 8a44ee9c38 | |||
| 22997f5df0 | |||
| 9ca1554333 | |||
| 2460a00d4e | |||
| f96daceb0f | |||
| 0c5cce2adc | |||
| e3a0d94236 | |||
| 3f03d46ff5 | |||
| c0c4acb73f | |||
| a35c264823 | |||
| 63752fe5cb | |||
| 9ab585f336 | |||
| 78b3a71450 | |||
| 3216e6a1c2 | |||
| a66583b883 | |||
| 4a7d5131fc | |||
| 56b1ff9a36 | |||
| b660336897 | |||
| af713f422b | |||
| 55ab0b7ceb | |||
| 93a94e9777 | |||
| 1bb669f3ca | |||
| 9ba745b5a9 | |||
| c13e640864 | |||
| f023480100 | |||
| 9acaf5e83a | |||
| 4e10c75fd0 | |||
| 88ac74e94c | |||
| 53ffef0ed1 | |||
| cfad4eab37 | |||
| d8e7a416d2 | |||
| f051e4b4af | |||
| c715c0e47a | |||
| c968088a3f | |||
| 2b32bfdfe1 | |||
| 16200c5500 | |||
| 1803d09095 | |||
| e29bad9a39 | |||
| 349b519a00 | |||
| 7fc524b593 | |||
| 4e139dc4b6 | |||
| 6481cf03e4 | |||
| 37c75c3887 | |||
| 8a0b2c03a1 | |||
| aa893d9cc1 | |||
| 91c062130c | |||
| 0aef2455fd | |||
| 6602b8c105 | |||
| dbbc8d2e7b | |||
| 1267caf43c | |||
| 015401861a | |||
| 9891e1aefb | |||
| 69ad161e36 | |||
| 485f890df3 | |||
| bf3ed0ede3 | |||
| 3f41eb7346 | |||
| 6cbd1ef298 | |||
| 94214f762e | |||
| 562c6ef6f6 | |||
| ccc8189d88 | |||
| 86594e4a8e | |||
| c2f1a83c1d | |||
| 6f8e5a9577 | |||
| bbfa816e57 | |||
| 5904eb03a2 | |||
| 87b6433ff7 | |||
| d7c9938f7e | |||
| 02434060ee |
+18
-126
@@ -18,7 +18,6 @@ permissions:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: cartsnitch/cartsnitch
|
IMAGE_NAME: cartsnitch/cartsnitch
|
||||||
AUTH_IMAGE_NAME: cartsnitch/auth
|
|
||||||
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
||||||
API_IMAGE_NAME: cartsnitch/api
|
API_IMAGE_NAME: cartsnitch/api
|
||||||
|
|
||||||
@@ -166,6 +165,8 @@ jobs:
|
|||||||
- name: Scan frontend image for vulnerabilities
|
- name: Scan frontend image for vulnerabilities
|
||||||
uses: anchore/scan-action@v5
|
uses: anchore/scan-action@v5
|
||||||
id: scan
|
id: scan
|
||||||
|
env:
|
||||||
|
GRYPE_CONFIG: .grype.yaml
|
||||||
with:
|
with:
|
||||||
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
|
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
fail-build: true
|
fail-build: true
|
||||||
@@ -196,97 +197,6 @@ jobs:
|
|||||||
git tag "v${{ steps.calver.outputs.version }}"
|
git tag "v${{ steps.calver.outputs.version }}"
|
||||||
git push origin "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 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 }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Scan auth image for vulnerabilities
|
|
||||||
uses: anchore/scan-action@v5
|
|
||||||
id: scan
|
|
||||||
with:
|
|
||||||
image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}"
|
|
||||||
fail-build: true
|
|
||||||
severity-cutoff: high
|
|
||||||
only-fixed: "true"
|
|
||||||
output-format: sarif
|
|
||||||
|
|
||||||
- name: Upload auth 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: ./auth
|
|
||||||
file: ./auth/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
|
|
||||||
build-and-push-receiptwitness:
|
build-and-push-receiptwitness:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
@@ -343,12 +253,16 @@ jobs:
|
|||||||
load: true
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APT_CACHE_BUST=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Scan receiptwitness image for vulnerabilities
|
- name: Scan receiptwitness image for vulnerabilities
|
||||||
uses: anchore/scan-action@v5
|
uses: anchore/scan-action@v5
|
||||||
id: scan
|
id: scan
|
||||||
|
env:
|
||||||
|
GRYPE_CONFIG: .grype.yaml
|
||||||
with:
|
with:
|
||||||
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
|
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
fail-build: true
|
fail-build: true
|
||||||
@@ -371,6 +285,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APT_CACHE_BUST=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|
||||||
build-and-push-api:
|
build-and-push-api:
|
||||||
@@ -429,12 +345,16 @@ jobs:
|
|||||||
load: true
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APT_CACHE_BUST=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Scan api image for vulnerabilities
|
- name: Scan api image for vulnerabilities
|
||||||
uses: anchore/scan-action@v5
|
uses: anchore/scan-action@v5
|
||||||
id: scan
|
id: scan
|
||||||
|
env:
|
||||||
|
GRYPE_CONFIG: .grype.yaml
|
||||||
with:
|
with:
|
||||||
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
|
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
fail-build: true
|
fail-build: true
|
||||||
@@ -457,11 +377,13 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APT_CACHE_BUST=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
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')
|
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||||
steps:
|
steps:
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
@@ -502,21 +424,6 @@ jobs:
|
|||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
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
|
- name: Determine image tag for receiptwitness
|
||||||
id: receiptwitness_tag
|
id: receiptwitness_tag
|
||||||
run: |
|
run: |
|
||||||
@@ -554,13 +461,13 @@ jobs:
|
|||||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
git add apps/overlays/dev/kustomization.yaml
|
git add apps/overlays/dev/kustomization.yaml
|
||||||
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
|
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
|
||||||
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
git commit -m "ci(dev): update cartsnitch, receiptwitness, and api images"
|
||||||
git pull --rebase origin main
|
git pull --rebase origin main
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
deploy-uat:
|
deploy-uat:
|
||||||
runs-on: runners-cartsnitch
|
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')
|
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
|
||||||
steps:
|
steps:
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
@@ -601,21 +508,6 @@ jobs:
|
|||||||
cd infra/apps/overlays/uat
|
cd infra/apps/overlays/uat
|
||||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
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
|
- name: Determine image tag for receiptwitness
|
||||||
id: receiptwitness_tag
|
id: receiptwitness_tag
|
||||||
run: |
|
run: |
|
||||||
@@ -653,6 +545,6 @@ jobs:
|
|||||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
git add apps/overlays/uat/kustomization.yaml
|
git add apps/overlays/uat/kustomization.yaml
|
||||||
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
|
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
|
||||||
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
|
git commit -m "ci(uat): update cartsnitch, receiptwitness, and api images"
|
||||||
git pull --rebase origin main
|
git pull --rebase origin main
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
+108
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.12-slim AS build
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
|
ARG APT_CACHE_BUST=0
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -12,6 +13,7 @@ RUN pip install --no-cache-dir --prefix=/install .
|
|||||||
|
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|
||||||
|
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/*
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
"""Redis/DragonflyDB caching helpers."""
|
"""Redis/DragonflyDB caching helpers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cartsnitch_api.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_redis: "Redis | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> "Settings":
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
async def init_redis() -> None:
|
||||||
|
global _redis
|
||||||
|
_redis = redis.from_url(settings.redis_url)
|
||||||
|
await _redis.ping()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
global _redis
|
||||||
|
if _redis is not None:
|
||||||
|
await _redis.aclose()
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> Redis | None:
|
||||||
|
return _redis
|
||||||
|
|
||||||
|
|
||||||
class CacheClient:
|
class CacheClient:
|
||||||
"""Redis/DragonflyDB caching with connection pooling.
|
"""Redis/DragonflyDB caching with connection pooling.
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
"""Database session management for the API gateway."""
|
"""Database session management for the API gateway."""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
engine = create_async_engine(
|
if TYPE_CHECKING:
|
||||||
settings.database_url,
|
from sqlalchemy.engine import Engine
|
||||||
echo=False,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=20,
|
_engine: "Engine | None" = None
|
||||||
pool_pre_ping=True,
|
async_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
|
||||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
def create_db_engine():
|
||||||
|
return create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
global _engine, async_session_factory
|
||||||
|
_engine = create_db_engine()
|
||||||
|
async_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
global _engine, async_session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
async_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""FastAPI dependency that yields an async DB session."""
|
if async_session_factory is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def dispose_engine() -> None:
|
# Backward compatibility: module-level engine proxy that delegates to _engine
|
||||||
"""Dispose the database engine, closing all pooled connections."""
|
def __getattr__(name: str):
|
||||||
await engine.dispose()
|
if name == "engine":
|
||||||
|
if _engine is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||||
|
return _engine
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ from cartsnitch_api.routes.user import router as user_router
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await cache_client.initialize()
|
from cartsnitch_api.database import init_db, close_db
|
||||||
|
from cartsnitch_api.cache import init_redis, close_redis
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
await init_redis()
|
||||||
yield
|
yield
|
||||||
await cache_client.close()
|
await close_redis()
|
||||||
await dispose_engine()
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Health check and error metrics endpoints."""
|
"""Health check and error metrics endpoints."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from cartsnitch_api.auth.dependencies import verify_service_key
|
from cartsnitch_api.auth.dependencies import verify_service_key
|
||||||
|
from cartsnitch_api.cache import get_redis
|
||||||
|
from cartsnitch_api.database import get_engine
|
||||||
from cartsnitch_api.middleware.error_handler import get_error_monitor
|
from cartsnitch_api.middleware.error_handler import get_error_monitor
|
||||||
|
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter(tags=["health"])
|
||||||
@@ -10,7 +13,27 @@ router = APIRouter(tags=["health"])
|
|||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
engine = get_engine()
|
||||||
|
db_ok = False
|
||||||
|
redis_ok = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = get_redis()
|
||||||
|
if r:
|
||||||
|
await r.ping()
|
||||||
|
redis_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
status = "ok" if db_ok else "degraded"
|
||||||
|
return {"status": status, "db": db_ok, "redis": redis_ok}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
|
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for Redis/DragonflyDB caching lifecycle."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cartsnitch_api.cache import CacheClient, close_redis, get_redis, init_redis
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_redis_creates_client():
|
||||||
|
"""Test that init_redis creates the Redis client."""
|
||||||
|
await init_redis()
|
||||||
|
try:
|
||||||
|
r = get_redis()
|
||||||
|
assert r is not None
|
||||||
|
await r.ping()
|
||||||
|
finally:
|
||||||
|
await close_redis()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_redis_clears_client():
|
||||||
|
"""Test that close_redis properly closes and clears the client."""
|
||||||
|
await init_redis()
|
||||||
|
await close_redis()
|
||||||
|
assert get_redis() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_client_get_returns_none_when_not_connected():
|
||||||
|
"""Test that CacheClient.get returns None gracefully when Redis is down."""
|
||||||
|
client = CacheClient()
|
||||||
|
# Without init_redis, get should return None
|
||||||
|
result = await client.get("test-key")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_client_set_does_not_raise_when_not_connected():
|
||||||
|
"""Test that CacheClient.set does not raise when Redis is down."""
|
||||||
|
client = CacheClient()
|
||||||
|
# Without init_redis, set should not raise
|
||||||
|
await client.set("test-key", "test-value", ttl_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_client_delete_does_not_raise_when_not_connected():
|
||||||
|
"""Test that CacheClient.delete does not raise when Redis is down."""
|
||||||
|
client = CacheClient()
|
||||||
|
# Without init_redis, delete should not raise
|
||||||
|
await client.delete("test-key")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for database initialization and lifecycle."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from cartsnitch_api.database import (
|
||||||
|
close_db,
|
||||||
|
create_db_engine,
|
||||||
|
get_engine,
|
||||||
|
init_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_db_engine_creates_engine_with_pool_settings():
|
||||||
|
"""Test that create_db_engine creates engine with correct pool settings."""
|
||||||
|
engine = create_db_engine()
|
||||||
|
assert engine is not None
|
||||||
|
pool = engine.pool
|
||||||
|
assert pool.size() == 10
|
||||||
|
assert pool._max_overflow == 20
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_db_sets_engine_and_factory():
|
||||||
|
"""Test that init_db properly initializes the engine and session factory."""
|
||||||
|
await init_db()
|
||||||
|
try:
|
||||||
|
eng = get_engine()
|
||||||
|
assert eng is not None
|
||||||
|
from cartsnitch_api import database
|
||||||
|
|
||||||
|
assert database.async_session_factory is not None
|
||||||
|
finally:
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_db_disposes_engine():
|
||||||
|
"""Test that close_db properly disposes the engine."""
|
||||||
|
await init_db()
|
||||||
|
await close_db()
|
||||||
|
assert get_engine() is None
|
||||||
|
from cartsnitch_api import database
|
||||||
|
|
||||||
|
assert database.async_session_factory is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_db_yields_session_after_init():
|
||||||
|
"""Test that get_db yields working sessions after init_db."""
|
||||||
|
await init_db()
|
||||||
|
try:
|
||||||
|
from cartsnitch_api.database import get_db
|
||||||
|
|
||||||
|
gen = get_db()
|
||||||
|
session = await gen.__anext__()
|
||||||
|
assert isinstance(session, AsyncSession)
|
||||||
|
await gen.aclose()
|
||||||
|
finally:
|
||||||
|
await close_db()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for health check endpoint."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from cartsnitch_api.database import init_db, close_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_db_and_redis_fields(client):
|
||||||
|
"""Test that health endpoint returns db and redis status fields."""
|
||||||
|
from cartsnitch_api.cache import init_redis, close_redis
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
await init_redis()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "status" in data
|
||||||
|
assert "db" in data
|
||||||
|
assert "redis" in data
|
||||||
|
finally:
|
||||||
|
await close_redis()
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_degraded_when_db_down():
|
||||||
|
"""Test that health returns degraded when database is down."""
|
||||||
|
from cartsnitch_api.database import _engine
|
||||||
|
from cartsnitch_api.routes.health import health
|
||||||
|
|
||||||
|
# Simulate engine is None (DB not initialized)
|
||||||
|
with patch("cartsnitch_api.routes.health.get_engine", return_value=None):
|
||||||
|
response = await health()
|
||||||
|
assert response["status"] == "degraded"
|
||||||
|
assert response["db"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_ok_when_db_up(client):
|
||||||
|
"""Test that health returns ok when database is up."""
|
||||||
|
from cartsnitch_api.database import init_db, close_db
|
||||||
|
from cartsnitch_api.cache import init_redis, close_redis
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
await init_redis()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
if data["db"]:
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
finally:
|
||||||
|
await close_redis()
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_redis_down_does_not_make_unhealthy(client):
|
||||||
|
"""Test that Redis being down does not make health return unhealthy."""
|
||||||
|
from cartsnitch_api.database import init_db, close_db
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get("/health")
|
||||||
|
data = response.json()
|
||||||
|
# Redis being down should not make status "degraded"
|
||||||
|
# Only DB failure makes it degraded
|
||||||
|
if not data["db"]:
|
||||||
|
assert data["status"] == "degraded"
|
||||||
|
finally:
|
||||||
|
await close_db()
|
||||||
+1
-1
@@ -37,7 +37,7 @@ export const auth = betterAuth({
|
|||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
password: {
|
password: {
|
||||||
hash: async (password: string) => {
|
hash: async (password: string) => {
|
||||||
return bcrypt.hash(password, 10);
|
return bcrypt.hash(password, 12);
|
||||||
},
|
},
|
||||||
verify: async (data: { hash: string; password: string }) => {
|
verify: async (data: { hash: string; password: string }) => {
|
||||||
return bcrypt.compare(data.password, data.hash);
|
return bcrypt.compare(data.password, data.hash);
|
||||||
|
|||||||
+1
-1
@@ -12,5 +12,5 @@
|
|||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add GIN index on normalized_products.upc_variants for fast JSON containment lookups.
|
||||||
|
|
||||||
|
Revision ID: 002_add_normalized_products_upc_variants_index
|
||||||
|
Revises: 001_add_email_inbound_token
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "002_add_normalized_products_upc_variants_index"
|
||||||
|
down_revision: str | None = "001_add_email_inbound_token"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_normalized_products_upc_variants",
|
||||||
|
"normalized_products",
|
||||||
|
["upc_variants"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_normalized_products_upc_variants", table_name="normalized_products")
|
||||||
@@ -4,7 +4,7 @@ import { mockAuthRoutes } from '../fixtures';
|
|||||||
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
||||||
|
|
||||||
test.describe('J1: Registration and Login', () => {
|
test.describe('J1: Registration and Login', () => {
|
||||||
test('can register a new account and see check your email screen', async ({ page }) => {
|
test('shows success message after registration', async ({ page }) => {
|
||||||
await mockAuthRoutes(page, false);
|
await mockAuthRoutes(page, false);
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
||||||
@@ -12,7 +12,8 @@ test.describe('J1: Registration and Login', () => {
|
|||||||
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible();
|
// Registration now shows "Account created! Please sign in." message
|
||||||
|
await expect(page.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows validation error when registration fields are empty', async ({ page }) => {
|
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||||
@@ -30,8 +31,16 @@ test.describe('J1: Registration and Login', () => {
|
|||||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
test('can sign in with valid credentials', async ({ page }) => {
|
||||||
await mockAuthRoutes(page, true);
|
await mockAuthRoutes(page, true);
|
||||||
|
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.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
|
||||||
|
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
|
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
|
||||||
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
||||||
|
|||||||
Generated
+3
-3
@@ -8164,9 +8164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
# 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.
|
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
|
||||||
|
ARG APT_CACHE_BUST=1
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -25,6 +26,7 @@ FROM python:3.12-slim AS prod
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Playwright system dependencies for Chromium
|
# Install Playwright system dependencies for Chromium
|
||||||
|
ARG APT_CACHE_BUST=1
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libatk1.0-0 \
|
libatk1.0-0 \
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ dependencies = [
|
|||||||
"cartsnitch-common>=0.1.0",
|
"cartsnitch-common>=0.1.0",
|
||||||
"playwright>=1.49,<2.0",
|
"playwright>=1.49,<2.0",
|
||||||
"playwright-stealth>=1.0,<2.0",
|
"playwright-stealth>=1.0,<2.0",
|
||||||
"cryptography>=42.0,<44.0",
|
"cryptography>=46.0,<47.0",
|
||||||
"fastapi>=0.115,<1.0",
|
"fastapi>=0.115,<1.0",
|
||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
"beautifulsoup4>=4.12,<5.0",
|
"beautifulsoup4>=4.12,<5.0",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function AlertCard({
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Target: ${alert.targetPrice.toFixed(2)}</span>
|
<span className="text-xs text-gray-500">Target: ${alert.targetPrice.toFixed(2)}</span>
|
||||||
<span className="text-xs text-gray-400">·</span>
|
<span className="text-xs text-gray-500">·</span>
|
||||||
<span className={`text-xs font-medium ${isBelow ? 'text-green-700' : 'text-gray-500'}`}>
|
<span className={`text-xs font-medium ${isBelow ? 'text-green-700' : 'text-gray-500'}`}>
|
||||||
Now: ${alert.currentPrice.toFixed(2)}
|
Now: ${alert.currentPrice.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -145,7 +145,7 @@ function AlertCard({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(alert.id)}
|
onClick={() => onDelete(alert.id)}
|
||||||
className="min-h-12 min-w-12 rounded-lg p-2 text-gray-400 active:bg-gray-100"
|
className="min-h-12 min-w-12 rounded-lg p-2 text-gray-500 active:bg-gray-100"
|
||||||
aria-label="Delete alert"
|
aria-label="Delete alert"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function Coupons() {
|
|||||||
<p className="mt-0.5 text-xs text-gray-500">{coupon.storeName}</p>
|
<p className="mt-0.5 text-xs text-gray-500">{coupon.storeName}</p>
|
||||||
<p
|
<p
|
||||||
className={`mt-1 text-xs ${
|
className={`mt-1 text-xs ${
|
||||||
expiringSoon ? 'font-medium text-orange-600' : 'text-gray-400'
|
expiringSoon ? 'font-medium text-orange-600' : 'text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Expires{' '}
|
Expires{' '}
|
||||||
|
|||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { authClient } from '../lib/auth-client.ts'
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
@@ -7,7 +7,6 @@ export function Login() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -29,11 +28,12 @@ export function Login() {
|
|||||||
throw new Error(authError.message ?? 'Sign in failed')
|
throw new Error(authError.message ?? 'Sign in failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful signIn, force a session fetch to confirm the cookie is set
|
// After successful signIn, force a full page reload so Better-Auth's
|
||||||
// before navigating to the protected route
|
// 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()
|
const sessionResult = await authClient.getSession()
|
||||||
if (sessionResult.data) {
|
if (sessionResult.data) {
|
||||||
navigate('/')
|
window.location.href = '/'
|
||||||
} else {
|
} else {
|
||||||
setError('Sign in failed. Please try again.')
|
setError('Sign in failed. Please try again.')
|
||||||
}
|
}
|
||||||
@@ -93,4 +93,4 @@ export function Login() {
|
|||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export function Purchases() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Item preview */}
|
{/* Item preview */}
|
||||||
<p className="mt-2 truncate text-xs text-gray-400">
|
<p className="mt-2 truncate text-xs text-gray-500">
|
||||||
{purchase.items
|
{purchase.items
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((i) => i.name)
|
.map((i) => i.name)
|
||||||
|
|||||||
+1
-47
@@ -8,9 +8,6 @@ export function Register() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [registrationComplete, setRegistrationComplete] = useState(false)
|
|
||||||
const [resendLoading, setResendLoading] = useState(false)
|
|
||||||
const [resendMessage, setResendMessage] = useState('')
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -38,7 +35,7 @@ export function Register() {
|
|||||||
throw new Error(authError.message ?? 'Registration failed')
|
throw new Error(authError.message ?? 'Registration failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setRegistrationComplete(true)
|
setError('Account created! Please sign in.')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Registration failed. Please try again.')
|
setError('Registration failed. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,49 +43,6 @@ export function Register() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
<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>
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function Settings() {
|
|||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-400">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
Supports Meijer, Kroger, and Target receipt emails.
|
Supports Meijer, Kroger, and Target receipt emails.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function StoreComparison() {
|
|||||||
{pp.price === lowestPrice ? (
|
{pp.price === lowestPrice ? (
|
||||||
<span className="text-xs font-medium text-green-600">Best price</span>
|
<span className="text-xs font-medium text-green-600">Best price</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-500">
|
||||||
+${(pp.price - lowestPrice).toFixed(2)}
|
+${(pp.price - lowestPrice).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -99,7 +99,7 @@ export function StoreComparison() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-gray-400">
|
<p className="mt-6 text-center text-xs text-gray-500">
|
||||||
Prices last verified from store loyalty card data. Map view coming soon.
|
Prices last verified from store loyalty card data. Map view coming soon.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user