Compare commits

..

5 Commits

Author SHA1 Message Date
CartSnitch Engineer Bot c116d0bc8a feat(ci): add deploy-uat job for UAT environment
Mirrors deploy-dev job but targets apps/overlays/uat. Both deploy-dev
and deploy-uat run in parallel after all build jobs complete.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 13:23:38 +00:00
cartsnitch-cto[bot] 94f99595fc fix(deps): resolve npm audit vulnerabilities (brace-expansion, lodash) (#108)
- Override brace-expansion to >=1.1.13 to resolve GHSA-f886-m6hf-6m8v
- Override lodash to >=4.17.24 to resolve GHSA-r5fr-rjxr-66jc and GHSA-f23m-r3pf-42rh
- Override minimatch to ^10.2.4 to maintain compatibility with brace-expansion@5.x

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
2026-04-03 13:23:20 +00:00
cartsnitch-qa[bot] c8de30ec6e Merge pull request #107 from cartsnitch/fix/inbound-email-500
fix: move email-in-address endpoint from /auth to /api/v1 prefix
2026-04-03 12:39:22 +00:00
CartSnitch Engineer Bot c1dc3e77e0 fix(receiptwitness): handle invalid timestamp in Mailgun webhook verification
Wrap int(timestamp) in try/except to return False instead of raising
ValueError on empty/invalid timestamp, which was causing a 500 error
instead of the intended 406.

Also add tests for empty timestamp (→ 406) and GET /inbound/email (→ 405).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 12:09:51 +00:00
CartSnitch Engineer Bot 1af98c40ab fix: move email-in-address endpoint from /auth to /api/v1 prefix
The GET /me/email-in-address endpoint was unreachable because the Gateway
HTTPRoute routes all /auth/* traffic to Better-Auth (port 3001), not the
API service. This change:
- Moves the endpoint from the /auth router to a new /api/v1/me/ router
- Adds EmailInAddressResponse schema and get_email_in_address service method
- Updates Settings.tsx to call /api/v1/me/email-in-address

Fixes CAR-445.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 11:44:31 +00:00
10 changed files with 687 additions and 653 deletions
+61
View File
@@ -400,3 +400,64 @@ jobs:
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git push origin main
deploy-uat:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && 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 }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update frontend image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
- 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:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
- name: Update api image tag
if: needs.build-and-push-api.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
git push origin main
+2
View File
@@ -18,6 +18,7 @@ from cartsnitch_api.routes.purchases import router as purchases_router
from cartsnitch_api.routes.scraping import router as scraping_router
from cartsnitch_api.routes.shopping import router as shopping_router
from cartsnitch_api.routes.stores import router as stores_router
from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
@@ -49,6 +50,7 @@ def create_app() -> FastAPI:
# Data endpoints mounted under /api/v1
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(user_router)
v1_router.include_router(stores_router)
v1_router.include_router(purchases_router)
v1_router.include_router(products_router)
+26
View File
@@ -0,0 +1,26 @@
"""User routes: per-user account endpoints (email-in address, etc.)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import EmailInAddressResponse
from cartsnitch_api.services.auth import AuthService
router = APIRouter(tags=["user"])
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
async def get_email_in_address(
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
try:
email_address = await svc.get_email_in_address(user_id)
return EmailInAddressResponse(email_address=email_address)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
+5
View File
@@ -1,6 +1,7 @@
"""Pydantic v2 request/response schemas for all API endpoints."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
@@ -21,6 +22,10 @@ class UserResponse(BaseModel):
created_at: datetime
class EmailInAddressResponse(BaseModel):
email_address: str
# ---------- Stores ----------
+11
View File
@@ -66,3 +66,14 @@ class AuthService:
await self.db.delete(user)
await self.db.commit()
async def get_email_in_address(self, user_id: str) -> str:
"""Return the per-user email-in address for receipt forwarding."""
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise LookupError("User not found")
return f"{user.email_inbound_token}@email.cartsnitch.com"
+548 -650
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -50,6 +50,9 @@
"overrides": {
"@rollup/pluginutils": "5.3.0",
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
"serialize-javascript": "7.0.5",
"brace-expansion": ">=1.1.13",
"lodash": ">=4.17.24",
"minimatch": "^10.2.4"
}
}
@@ -17,7 +17,11 @@ TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@")
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
"""Verify Mailgun webhook signature."""
if abs(time.time() - int(timestamp)) > 300: # 5 min freshness
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
if abs(time.time() - ts) > 300: # 5 min freshness
return False
key = settings.mailgun_webhook_signing_key.encode()
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
@@ -99,3 +99,27 @@ def test_stale_timestamp(client, mock_redis):
assert response.status_code == 406
assert response.json()["detail"] == "Invalid signature"
mock_redis["enqueue"].assert_not_awaited()
def test_invalid_timestamp_returns_406(client, mock_redis):
"""Empty timestamp should return 406, not 500."""
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
form = {
"token": "test-token",
"timestamp": "",
"signature": "any-sig",
"sender": "sender@example.com",
"recipient": "receipts+user123@example.com",
"subject": "Receipt",
}
response = client.post("/inbound/email", data=form)
assert response.status_code == 406
assert response.json()["detail"] == "Invalid signature"
mock_redis["enqueue"].assert_not_awaited()
def test_get_inbound_email_returns_405(client):
"""GET /inbound/email is not allowed."""
response = client.get("/inbound/email")
assert response.status_code == 405
+1 -1
View File
@@ -15,7 +15,7 @@ export function Settings() {
useEffect(() => {
if (!session?.user) return
fetch('/auth/me/email-in-address', {
fetch('/api/v1/me/email-in-address', {
credentials: 'include',
})
.then((res) => res.json())