Skip to content

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:

PhaseDescriptionStatus
Phase 1Client-side geofence validation✅ Complete
Phase 2Server-side Cloud Function validation✅ Complete
Phase 3Unit tests (28 tests)✅ Complete
Phase 4UX improvements⬜ Future enhancement

Files Changed

Testing

Run the unit tests:

bash
pm test -- --run src/__tests__/geofencing.test.js src/__tests__/lanternProximity.test.js

For 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:

  1. Modify JavaScript — Open DevTools, find the distance check, and disable it
  2. Write directly to Firestore — Use the Firebase SDK to create lantern documents without going through the app
  3. Spoof GPS coordinates — Send fake coordinates from a modified request

Server-side validation (Phase 2) eliminates these attacks:

AttackClient-OnlyWith 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:

  1. User Experience: Users can "check in" to venues they're not actually at, defeating the purpose of location-based connections
  2. Merchant Trust: Merchants offering deals expect users to be physically present
  3. Security: No server-side validation of location claims
  4. 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.radius field (default 100m)
  • Server-side verification - lightLanternSecure Cloud 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 radius field 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

Proposed Solution

Phase 1: Client-Side Geofence Validation (REQUIRED)

Add proximity check before allowing lantern lighting:

javascript
// 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

Add Cloud Function to validate location on backend:

javascript
// 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)

javascript
// 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:

markdown
# 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 away

Acceptance 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:

jsx
// 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

jsx
{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

PhasePriorityEffortImpactTimeline
Phase 1: Client ValidationP0 (Critical)2-4 hoursHighImmediate
Phase 3: Unit TestsP0 (Critical)4-6 hoursHighWeek 1
Phase 2: Server ValidationP1 (High)6-8 hoursMediumWeek 2
Phase 4: UX ImprovementsP2 (Nice-to-have)3-5 hoursLowWeek 3


Open Questions

  1. GPS Accuracy Threshold: Should we allow a 10-15m margin for GPS error? (e.g., venue radius + 15m tolerance)
  2. Retry Logic: If user is at 110m and approaching, should we show "Walk 10m closer and try again"?
  3. Scheduled Lights: Should proximity check happen at scheduling time or lighting time?
  4. Merchant Override: Should merchants be able to disable geofence for special events?
  5. 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

Built with VitePress