Tighten Geofencing & Add Proximity Testing for Lantern Lighting
Date: 2026-01-18
Status: ✅ Complete
Issue: #28
Related Feature(s): Global Lantern Flows
Implementation Summary
This issue has been fully implemented as of 2026-01-18. The following phases are complete:
| Phase | Description | Status |
|---|---|---|
| Phase 1 | Client-side geofence validation | ✅ Complete |
| Phase 2 | Server-side Cloud Function validation | ✅ Complete |
| Phase 3 | Unit tests (28 tests) | ✅ Complete |
| Phase 4 | UX improvements | ⬜ Future enhancement |
Files Changed
- src/lib/lanternService.js - Added proximity validation, Cloud Function integration
- src/lib/venueService.js - Exported
calculateDistance, fixed lantern count updates - functions/index.js - Added
lightLanternSecureandextinguishLanternSecure - firestore.rules - Fixed venue update rule for lantern count
- src/tests/geofencing.test.js - 15 unit tests for distance calculation
- src/tests/lanternProximity.test.js - 13 tests for proximity validation
Testing
Run the unit tests:
pm test -- --run src/__tests__/geofencing.test.js src/__tests__/lanternProximity.test.jsFor manual testing, see Venue Testing Guide - Geofencing Section.
Why Server-Side Validation Prevents Fraud
Client-side validation (Phase 1) provides good UX but is not secure. A malicious user can:
- Modify JavaScript — Open DevTools, find the distance check, and disable it
- Write directly to Firestore — Use the Firebase SDK to create lantern documents without going through the app
- Spoof GPS coordinates — Send fake coordinates from a modified request
Server-side validation (Phase 2) eliminates these attacks:
| Attack | Client-Only | With Cloud Function |
|---|---|---|
| Modify app JavaScript | ❌ Vulnerable | ✅ Protected — server re-validates |
| Direct Firestore write | ❌ Vulnerable | ✅ Protected — can require function |
| Fake coordinates in request | ❌ Vulnerable | ⚠️ Mitigated — server fetches real venue location |
| GPS spoofing on device | ❌ Vulnerable | ⚠️ Mitigated — pattern detection possible |
Note: Users can still send fake coordinates to the Cloud Function, but the server:
- Fetches the real venue location from Firestore (user can't fake this)
- Calculates distance using the real venue coords (tamper-proof math)
- Can log suspicious patterns for fraud review
For complete fraud prevention (e.g., GPS spoofing), future enhancements could include:
- Bluetooth beacon verification (physical presence proof)
- WiFi network validation (connected to venue's WiFi)
- Machine learning fraud scoring (detect impossible travel patterns)
Original Problem Statement
Currently, users can light a lantern at a venue from significant distances (up to 5km search radius). There is no proximity validation to ensure users are actually at the venue before lighting a lantern. This creates several issues:
- User Experience: Users can "check in" to venues they're not actually at, defeating the purpose of location-based connections
- Merchant Trust: Merchants offering deals expect users to be physically present
- Security: No server-side validation of location claims
- Testing Gap: No automated tests for geofence boundaries or proximity validation
Current Implementation Analysis
What Works (After Implementation)
- ✅ Location spoofing for development (via
src/lib/locationSpoof.js) - ✅ Haversine distance calculation (in
src/lib/venueService.js) - ✅ 5km search radius for discovering nearby venues
- ✅ Client-side proximity check before lighting lantern - Users must be within venue geofence
- ✅ Geofence enforcement - Uses
venue.radiusfield (default 100m) - ✅ Server-side verification -
lightLanternSecureCloud Function validates proximity - ✅ 28 automated tests - Unit tests for distance calculation and proximity validation
- ✅ Testing documentation - Geofencing testing guide added
What Was Missing (Before Implementation)
- ❌ No proximity check before lighting lantern - Users could light from anywhere in the 5km radius
- ❌ No geofence enforcement - Merchant-defined
radiusfield was not validated - ❌ No client-side proximity validation -
lightLantern()accepted any coordinates - ❌ No server-side verification - Cloud Functions didn't validate location
- ❌ No automated tests - No unit or integration tests for proximity logic
- ❌ No cross-device testing - Mobile GPS accuracy issues not documented
Key Code References
- Lantern lighting: src/lib/lanternService.js -
lightLantern()function - Distance calculation: src/lib/venueService.js - Haversine formula
- Merchant geofence: src/screens/merchant/OfferForm.jsx -
radiusfield (default 100m) - Venue discovery: src/lib/venueService.js -
getNearbyVenues()with 5km radius
Proposed Solution
Phase 1: Client-Side Geofence Validation (REQUIRED)
Add proximity check before allowing lantern lighting:
// In src/lib/lanternService.js - lightLantern()
export async function lightLantern(userId, venueId, userLat, userLng, formData = null) {
try {
// Get venue details
const venue = await getVenue(venueId)
// NEW: Validate user is within venue's geofence
const distanceMeters = calculateDistance(userLat, userLng, venue.lat, venue.lng)
const allowedRadius = venue.radius || 100 // Default 100m
if (distanceMeters > allowedRadius) {
throw new Error(
`You must be within ${allowedRadius}m of ${venue.name} to light a lantern. ` +
`You are currently ${Math.round(distanceMeters)}m away.`
)
}
// ... rest of existing logic
}
}Benefits:
- Immediate UX improvement
- Clear error messages to users
- Respects merchant-defined geofence radius
- Works with existing location spoofing for dev/testing
Acceptance Criteria:
- [ ] User cannot light lantern if >100m from venue (or custom
venue.radius) - [ ] Error message shows distance and required proximity
- [ ] Works with location spoofing in dev mode
- [ ] No regression in normal lantern lighting flow
Phase 2: Server-Side Verification (RECOMMENDED)
Add Cloud Function to validate location on backend:
// functions/validateProximity.js (NEW FILE)
exports.validateLanternProximity = functions.https.onCall(async (data, context) => {
const { venueId, userLat, userLng } = data
const userId = context.auth.uid
// Fetch venue from Firestore
const venue = await admin.firestore().collection('venues').doc(venueId).get()
// Calculate distance
const distance = haversineDistance(userLat, userLng, venue.lat, venue.lng)
const allowedRadius = venue.radius || 100
if (distance > allowedRadius) {
throw new functions.https.HttpsError(
'failed-precondition',
`User is ${distance}m from venue, exceeds ${allowedRadius}m geofence`
)
}
return { valid: true, distance }
})Benefits:
- Prevents client-side tampering
- Audit trail for fraud detection
- Can log suspicious activity
- Required for merchant redemptions
Acceptance Criteria:
- [ ] Cloud Function deployed and callable
- [ ] Returns 403 if user outside geofence
- [ ] Logs verification attempts to Firestore
- [ ] Client calls function before
lightLantern()
Phase 3: Automated Testing Suite (CRITICAL)
Unit Tests (Vitest)
// src/__tests__/geofencing.test.js (NEW FILE)
describe('Geofencing', () => {
describe('calculateDistance', () => {
it('calculates distance between two coordinates accurately', () => {
const distance = calculateDistance(32.7157, -117.1611, 32.7167, -117.1601)
expect(distance).toBeCloseTo(138, 0) // ~138 meters
})
it('returns 0 for same coordinates', () => {
expect(calculateDistance(32.7157, -117.1611, 32.7157, -117.1611)).toBe(0)
})
})
describe('lightLantern proximity validation', () => {
it('throws error when user too far from venue', async () => {
// Mock venue 500m away
await expect(
lightLantern(userId, venueId, userLat, userLng)
).rejects.toThrow('You must be within')
})
it('allows lighting when within geofence', async () => {
// Mock venue 50m away
const lantern = await lightLantern(userId, venueId, userLat, userLng)
expect(lantern).toBeDefined()
})
it('respects custom venue radius', async () => {
// Test with venue.radius = 200m
})
})
})Integration Tests (Cross-Device)
Create docs/engineering/testing/GEOFENCE_TESTING.md:
# Geofence Testing Guide
## Manual Test Cases
### Test 1: Within Geofence (Happy Path)
- **Setup**: Stand within 50m of venue
- **Action**: Light lantern
- **Expected**: Success, lantern lit
### Test 2: Outside Geofence (Error Path)
- **Setup**: Stand 150m from venue
- **Action**: Attempt to light lantern
- **Expected**: Error: "You must be within 100m..."
### Test 3: Edge Case - Exactly at Boundary
- **Setup**: Position at exactly 100m from venue
- **Action**: Light lantern
- **Expected**: Success (boundary inclusive)
### Test 4: Custom Venue Radius
- **Setup**: Venue has radius=200m, user at 150m
- **Action**: Light lantern
- **Expected**: Success
## Cross-Device Testing Matrix
| Device | GPS Accuracy | Test Result | Notes |
|--------|--------------|-------------|-------|
| iPhone 14 | High (~5m) | PASS | Best accuracy |
| Pixel 7 | High (~5m) | PASS | Comparable to iOS |
| Budget Android | Medium (~15m) | ? | Test with margin |
| Indoor/Urban Canyon | Low (~50m) | ? | May fail geofence |
## Dev Mode Testing
Use location spoofing to simulate:
- User at venue (lat/lng match)
- User 50m away
- User 150m away
- User 5km awayAcceptance Criteria:
- [ ] Unit tests for
calculateDistance()with known distances - [ ] Unit tests for
lightLantern()proximity validation - [ ] Integration tests for happy/error paths
- [ ] Cross-device testing matrix documented
- [ ] Location spoofing tests (dev mode)
- [ ] 100% code coverage for geofencing logic
Phase 4: UX Improvements (NICE-TO-HAVE)
Visual Proximity Indicator
Add to VenuePicker component:
// Show proximity status in venue list
<div className="flex items-center gap-2">
{venue.distanceMeters <= venue.radius ? (
<span className="text-green-500">✓ In range</span>
) : (
<span className="text-red-500">✗ {Math.round(venue.distanceMeters)}m away</span>
)}
</div>"Get Directions" for Out-of-Range Venues
{venue.distanceMeters > venue.radius && (
<button onClick={() => openMaps(venue)}>
Get Directions ({Math.round(venue.distanceMeters)}m away)
</button>
)}Acceptance Criteria:
- [ ] Venue list shows proximity status
- [ ] Disable "Light Lantern" button if out of range
- [ ] "Get Directions" link for far venues
- [ ] Live proximity updates (if user moves)
Testing Checklist
Unit Tests
- [ ]
calculateDistance()accuracy tests - [ ]
lightLantern()proximity validation - [ ] Custom venue radius handling
- [ ] Error message formatting
Integration Tests
- [ ] End-to-end lantern lighting within geofence
- [ ] End-to-end rejection outside geofence
- [ ] Location spoofing in dev mode
- [ ] Cloud Function validation (Phase 2)
Manual Testing
- [ ] Real device testing at actual venue
- [ ] Test with iOS Safari
- [ ] Test with Chrome Android
- [ ] Test in urban canyon (poor GPS)
- [ ] Test indoors (GPS drift)
- [ ] Test at venue boundary (edge case)
Cross-Device
- [ ] iPhone 14+ (High GPS accuracy)
- [ ] Pixel 7+ (High GPS accuracy)
- [ ] Budget Android (Medium accuracy)
- [ ] iPad/Tablet (often lower accuracy)
Success Metrics
Before (Current State):
- Users can light lanterns from 5km away ❌
- No proximity validation ❌
- No automated tests ❌
After (Goal State):
- Users must be within venue geofence (default 100m) ✅
- Client + server-side validation ✅
- 100% test coverage for geofencing logic ✅
- Cross-device testing documented ✅
- Clear error messages for out-of-range users ✅
Implementation Priority
| Phase | Priority | Effort | Impact | Timeline |
|---|---|---|---|---|
| Phase 1: Client Validation | P0 (Critical) | 2-4 hours | High | Immediate |
| Phase 3: Unit Tests | P0 (Critical) | 4-6 hours | High | Week 1 |
| Phase 2: Server Validation | P1 (High) | 6-8 hours | Medium | Week 2 |
| Phase 4: UX Improvements | P2 (Nice-to-have) | 3-5 hours | Low | Week 3 |
Related Issues / Docs
- Global Lantern Flows - Lists "Geofence check before lighting" as near-term enhancement
- Venue Testing Guide - Current venue/lantern testing docs
- Database Scaling - Geofencing - Geofencing strategy overview
- User Security Guide - User-facing privacy docs (mentions proximity checks)
Open Questions
- GPS Accuracy Threshold: Should we allow a 10-15m margin for GPS error? (e.g., venue radius + 15m tolerance)
- Retry Logic: If user is at 110m and approaching, should we show "Walk 10m closer and try again"?
- Scheduled Lights: Should proximity check happen at scheduling time or lighting time?
- Merchant Override: Should merchants be able to disable geofence for special events?
- Fraud Detection: Should we log suspicious patterns (e.g., user "teleporting" between venues)?
Notes
- This issue blocks merchant redemption features (can't trust user location)
- Critical for MVP launch - users must actually be at venues
- Location spoofing already in place for development/testing
- Haversine distance calculation already implemented
- Most work is wiring existing pieces together + testing