Venue API โ
The Venue API is a server-side Cloud Run service that handles all venue data operations: importing from OpenStreetMap (OSM), enriching addresses via Nominatim, and maintaining venue data quality. It replaced a previous client-side import flow to improve security, rate limiting, and deduplication.
Source: services/api/venues/Client helper: apps/web/src/lib/venueApiClient.jsShared config: packages/shared/venues/index.js
| Dev | Prod | |
|---|---|---|
| Firebase project | lantern-app-dev | lantern-app-prod |
| Cloud Run region | us-central1 | us-central1 |
| Service URL | https://venue-api-531553779372.us-central1.run.app | Set via VITE_VENUE_API_URL in prod env |
| Runtime | Node 22 / Express 5 | Node 22 / Express 5 |
Table of Contents โ
- Architecture Overview
- Interactive API Docs
- Authentication
- Rate Limits
- Endpoints
- Error Responses
- Local Development
- Testing
- Deploying
- Verifying a Deployment
- Checking Logs
- Frontend Integration
- Architecture Deep Dive
Architecture Overview โ
Browser / Admin Panel
โ Firebase ID token (Bearer)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ venue-api (Cloud Run) โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ auth middleware โ โ โ verifies Firebase token
โ โ rbac middleware โ โ โ checks Firestore users.role
โ โ rateLimiter โ โ โ per-user sliding window
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ import routes โโโโโโโ Overpass API (OSM)
โ โ refresh routes โโโโโโโ Nominatim (geocoding)
โ โ utility routes โ โ
โ โ admin routes โ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ venue.service โโโโโโโโบ Firestore (venues collection)
โ โโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโWhy server-side? Previously, venue imports called Overpass and wrote to Firestore directly from the browser. Moving this server-side lets us:
- Enforce rate limits against Overpass/Nominatim API policies
- Prevent clients from writing arbitrary venue documents
- Run deduplication reliably without client-side race conditions
- Batch Firestore writes at the 500-doc limit
Interactive API Docs โ
The Venue API exposes its OpenAPI 3.0 spec at /openapi.json. Interactive docs are rendered by the Lantern admin portal under API Reference โ Venues (powered by @lantern/api-docs-renderer); the service itself does not mount a doc UI.
| URL | Environment |
|---|---|
http://localhost:8080/openapi.json | Local development (raw spec) |
https://venue-api-531553779372.us-central1.run.app/openapi.json | Dev (Cloud Run, raw spec) |
| Admin portal โ API Reference โ Venues | Interactive docs (any env) |
To test authenticated endpoints in the admin portal:
- Sign in to the admin portal as an admin user
- Open API Reference โ Venues
- The renderer auto-fills your current Firebase ID token; the Authorization header is sent on every request
The raw spec is also available at /openapi.json for import into Postman, Insomnia, or any OpenAPI-compatible tooling.
Source files:
- Service:
services/api/venues/src/index.js(serves/openapi.json) - Spec:
services/api/venues/openapi.json - Renderer:
packages/api-docs-renderer
Authentication โ
All endpoints except GET /health require a Firebase ID token in the Authorization header:
Authorization: Bearer <firebase-id-token>Getting a token (browser):
import { auth } from '../firebase'
const token = await auth.currentUser.getIdToken()Getting a token (curl โ dev only):
# Use the Firebase Auth REST API to exchange email/password for an ID token
curl -X POST \
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=<FIREBASE_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}'
# Copy the "idToken" field from the responseWhat the middleware does:
- Checks for
Authorization: Bearer <token>header - Calls
firebase-admin.auth().verifyIdToken(token) - Attaches
req.user = { uid, email }on success - Returns
401 UNAUTHORIZEDif missing or invalid
Admin routes additionally read users/{uid}.role from Firestore and check it against the role hierarchy (user < merchant < admin) before continuing.
Rate Limits โ
Per-User Limits (sliding window, per-minute) โ
| Action | Limit | Applies To |
|---|---|---|
import | 5 req / min | POST /venues/import/osm |
refresh | 10 req / min | POST /venues/refresh/batch |
enrich | 20 req / min | POST /venues/refresh/enrich/:id, POST /venues/geocode |
External API Source Limits (per-second, server-side) โ
| Source | Limit | Reason |
|---|---|---|
| OSM Overpass | 5 req / sec | Overpass fair-use policy |
| Nominatim | 1 req / 1.1 sec | Nominatim usage policy |
| Google Places | 10 req / sec | (future) |
When a per-user limit is hit, the API responds:
{
"error": "RATE_LIMITED",
"message": "Too many import requests. Please try again later.",
"retryAfterMs": 42000
}Endpoints โ
Health Check โ
GET /health โ
No authentication required. Returns service status.
Response 200:
{
"status": "ok",
"service": "venue-api",
"timestamp": "2026-02-10T12:00:00.000Z"
}curl example:
curl https://venue-api-531553779372.us-central1.run.app/healthImport โ
All import routes require Authorization: Bearer <token> and are rate-limited to 5 requests per minute per user.
POST /venues/import/osm โ
Import venues from OpenStreetMap's Overpass API for a geographic area. Deduplicates against existing venues by osmId. Updates geohash-based refresh metadata.
Request body:
| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
lat | number | โ | โ | -90 to 90 |
lng | number | โ | โ | -180 to 180 |
radius | number | 5000 | 100โ50000 (meters) | |
includeGold | boolean | true | Bars, cafes, pubs, libraries | |
includeSilver | boolean | true | Gyms, arcades, dance halls, pools, etc. | |
includeBronze | boolean | true | Restaurants, fast food, ice cream |
Tier definitions (from @lantern/shared/venues):
- Gold:
bar,biergarten,cafe,pub,library - Silver: gyms, arcades, dance halls, sports centres, pools, saunas, etc.
- Bronze: restaurants, fast food, ice cream shops
- Coal (always excluded): parks, playgrounds, dog parks, schools, colleges
Response 200:
{
"success": true,
"imported": 42,
"skipped": 8,
"total": 50
}skipped = venues already in Firestore (matched by osmId). total = all venues fetched from Overpass.
Response 400 (validation error):
{
"error": "VALIDATION_ERROR",
"message": "Invalid request data",
"details": [{ "path": ["lat"], "message": "Number must be less than or equal to 90" }]
}curl example:
TOKEN="your-firebase-id-token"
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/import/osm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"lat": 32.7649, "lng": -117.1246, "radius": 5000}'POST /venues/import/hybrid โ
Reserved for a future combined OSM + Google Places import. Currently returns 501 Not Implemented.
Response 501:
{
"error": "NOT_IMPLEMENTED",
"message": "Hybrid import is not yet available. Use /venues/import/osm."
}Refresh / Enrichment โ
All refresh routes require Authorization: Bearer <token>.
POST /venues/refresh/enrich/:venueId โ
Enrich a single venue with a reverse-geocoded address from Nominatim. Skips if the venue already has addressComponents.
Rate limit: 20 req / min (enrich bucket).
Path param: venueId โ Firestore document ID
Response 200 (enriched):
{
"success": true,
"venueId": "abc123",
"address": "123 Main St, San Diego, CA 92101, USA",
"addressComponents": {
"houseNumber": "123",
"road": "Main St",
"city": "San Diego",
"state": "California",
"postcode": "92101",
"country": "United States",
"countryCode": "us"
}
}Response 200 (skipped):
{
"success": true,
"skipped": true,
"reason": "already_enriched",
"venueId": "abc123"
}Possible reason values: already_enriched, no_coordinates, nominatim_error.
Response 404:
{ "error": "NOT_FOUND", "message": "Venue not found" }curl example:
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/refresh/enrich/abc123 \
-H "Authorization: Bearer $TOKEN"POST /venues/refresh/batch โ
Batch-enrich multiple venues. Processes sequentially (one per ~1.1 sec) to respect Nominatim's rate limit. Max 100 venues per request.
Rate limit: 10 req / min (refresh bucket).
Request body:
| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
venueIds | string[] | โ | โ | 1โ100 items |
priority | "high" | "normal" | "normal" | Future use |
Response 200:
{
"success": true,
"enriched": 10,
"skipped": 3,
"errors": 1,
"details": [
{ "venueId": "abc123", "status": "enriched", "address": "123 Main St..." },
{ "venueId": "def456", "status": "already_enriched" },
{ "venueId": "ghi789", "status": "enrichment_failed" }
]
}Possible status values per venue: enriched, not_found, already_enriched, no_coordinates, enrichment_failed, error.
curl example:
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/refresh/batch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"venueIds": ["abc123", "def456", "ghi789"]}'POST /venues/refresh/scheduled โ
Automated enrichment endpoint intended for Cloud Scheduler. Finds venues missing addressComponents and enriches up to limit of them.
In production: requires the X-CloudScheduler-JobName header โ this is automatically set by Google Cloud Scheduler and cannot be spoofed. Omitting it returns 403 FORBIDDEN.
In development: the header check is skipped.
Query params:
| Param | Default | Max |
|---|---|---|
limit | 50 | 50 |
Response 200:
{
"success": true,
"enriched": 45,
"errors": 2
}Utility โ
All utility routes require Authorization: Bearer <token>.
POST /venues/geocode โ
Reverse geocode coordinates to a formatted address via Nominatim. Forward geocoding (address โ lat/lng) is not yet implemented.
Rate limit: 20 req / min (enrich bucket).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
lat | number | โ (for reverse) | -90 to 90 |
lng | number | โ (for reverse) | -180 to 180 |
address | string | (for forward) | Not yet implemented |
Either lat + lng or address must be provided.
Response 200 (reverse geocode):
{
"success": true,
"type": "reverse",
"address": "123 Main St, San Diego, CA 92101, USA",
"displayName": "Balboa Park, San Diego, San Diego County, California, United States"
}Response 501 (forward geocoding attempt):
{
"error": "NOT_IMPLEMENTED",
"message": "Forward geocoding is not yet available. Provide lat and lng for reverse geocoding."
}curl example:
curl -X POST https://venue-api-531553779372.us-central1.run.app/venues/geocode \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"lat": 32.7649, "lng": -117.1246}'GET /venues/:venueId/metadata โ
Fetch a venue document with source provenance and data quality metrics.
Response 200:
{
"venue": { "name": "The Pub", "lat": 32.76, "lng": -117.12, "category": "bar", "..." : "..." },
"id": "abc123",
"sources": ["osm"],
"lastUpdated": "2026-01-15T08:00:00Z",
"quality": {
"hasAddress": true,
"hasOpeningHours": false,
"hasContact": true,
"searchTermCount": 6
}
}Response 404:
{ "error": "NOT_FOUND", "message": "Venue not found" }curl example:
curl https://venue-api-531553779372.us-central1.run.app/venues/abc123/metadata \
-H "Authorization: Bearer $TOKEN"POST /venues/consolidate โ
Find and merge duplicate venues that share the same osmId. Dry-run by default โ set dryRun: false to execute.
Request body:
| Field | Type | Default |
|---|---|---|
dryRun | boolean | true |
Response 200 (dry run):
{
"success": true,
"dryRun": true,
"duplicatesFound": 3,
"totalDuplicateDocuments": 5,
"duplicates": [
{ "osmId": "12345", "count": 3, "ids": ["abc", "def", "ghi"] }
]
}Response 200 (executed):
{
"success": true,
"dryRun": false,
"duplicatesFound": 3,
"deleted": 5
}Admin โ
All admin routes require Authorization: Bearer <token> and the authenticated user must have role: "admin" in Firestore.
GET /venues/admin/stats โ
Venue collection statistics including duplicate count.
Response 200:
{
"success": true,
"total": 4821,
"duplicateOsmIds": 2,
"totalDuplicateDocuments": 3
}curl example:
curl https://venue-api-531553779372.us-central1.run.app/venues/admin/stats \
-H "Authorization: Bearer $ADMIN_TOKEN"POST /venues/admin/cleanup/orphaned โ
Remove venues with zero active lanterns and no activity for a configurable number of days (default: 90). Dry-run by default.
Request body:
| Field | Type | Default | Notes |
|---|---|---|---|
dryRun | boolean | true | Set false to delete |
daysInactive | number | 90 | Days since creation with no lanterns |
Response 200 (dry run):
{
"success": true,
"dryRun": true,
"orphanedCount": 12,
"orphaned": [
{ "id": "abc123", "name": "Closed Bar" }
]
}Response 200 (executed):
{
"success": true,
"deleted": 12,
"errors": []
}POST /venues/admin/validate/all โ
Run data quality checks across all venues. Can optionally auto-fix missing geohashes. Max 5000 venues per call.
Request body:
| Field | Type | Default | Notes |
|---|---|---|---|
autoFix | boolean | false | Rebuilds missing geohashes (max 500) |
limit | number | 1000 | Max venues to check (hard cap: 5000) |
Response 200:
{
"success": true,
"total": 1000,
"totalIssues": 7,
"fixed": 3,
"issues": {
"missingName": 1,
"missingCoordinates": 0,
"missingGeohash": 3,
"missingCategory": 2,
"invalidCoordinates": 1
}
}POST /venues/admin/deduplicate โ
Find and remove duplicate venues grouped by osmId. Keeps the oldest document, deletes newer duplicates. Dry-run by default.
Request body:
| Field | Type | Default |
|---|---|---|
dryRun | boolean | true |
Response 200 (dry run):
{
"success": true,
"dryRun": true,
"duplicatesFound": 4,
"totalToDelete": 7,
"sample": [
{ "osmId": "12345", "count": 3, "ids": ["a", "b", "c"] }
]
}Response 200 (executed):
{
"success": true,
"dryRun": false,
"duplicatesFound": 4,
"deleted": 7
}Error Responses โ
All errors follow a consistent shape:
{
"error": "ERROR_CODE",
"message": "Human-readable description",
"details": []
}| HTTP Status | Error Code | Cause |
|---|---|---|
400 | VALIDATION_ERROR | Zod schema failure โ details array has per-field errors |
401 | UNAUTHORIZED | Missing, malformed, or expired Bearer token |
403 | FORBIDDEN | Valid token but insufficient role for this endpoint |
403 | FORBIDDEN | POST /venues/refresh/scheduled called without Scheduler header in prod |
404 | NOT_FOUND | Venue document doesn't exist in Firestore |
429 | RATE_LIMITED | Per-user rate limit exceeded โ retryAfterMs tells you when to retry |
500 | INTERNAL_ERROR | Unexpected server error โ check Cloud Run logs |
501 | NOT_IMPLEMENTED | Feature exists in the API design but isn't implemented yet |
Local Development โ
Prerequisites โ
- Node 22+
- Firebase project credentials (copy
.env.local.exampleโ.env.local) FIREBASE_PROJECT_IDset in your environment
Running the service โ
# From the monorepo root:
npm run dev -w services/api/venues
# Or from within the service directory:
cd services/api/venues
npm run devThe dev script uses node --env-file=../../../.env.local --watch, which:
- Loads your
.env.localcredentials - Auto-restarts on file changes
The service listens on http://localhost:8080 by default.
Verifying it's running โ
curl http://localhost:8080/health
# {"status":"ok","service":"venue-api","timestamp":"..."}Making authenticated calls locally โ
Get a Firebase ID token for a test user. The easiest way is from the browser console on the dev app:
// In browser devtools on https://dev.ourlantern.app
const token = await firebase.auth().currentUser.getIdToken()
console.log(token)Then use it in curl:
TOKEN="<paste token here>"
# Import venues around San Diego downtown
curl -X POST http://localhost:8080/venues/import/osm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"lat": 32.7157, "lng": -117.1611, "radius": 2000, "includeBronze": false}'Environment variables โ
The service reads these environment variables:
| Variable | Required | Description |
|---|---|---|
FIREBASE_PROJECT_ID | โ | Firebase project (lantern-app-dev or lantern-app-prod) |
NODE_ENV | production enables stricter scheduler header check; defaults to development | |
PORT | Port to listen on; defaults to 8080 |
In .env.local, set:
FIREBASE_PROJECT_ID=lantern-app-devThe frontend also needs:
VITE_VENUE_API_URL=http://localhost:8080Testing โ
Tests live in services/api/venues/test/ and run with Vitest.
Running tests โ
# From monorepo root:
npm run validate -w services/api/venues
# Watch mode during development:
cd services/api/venues
npm test
# Single run:
npm run test:runTest files โ
| File | What it tests |
|---|---|
routes.integration.test.js | Full route pipeline: auth, RBAC, validation, response shapes |
osm.service.test.js | buildVenueFromOSM, lifecycle detection, category normalization |
nominatim.service.test.js | Reverse geocoding, address field extraction |
rateLimiter.test.js | Sliding window accuracy, per-user and per-source limits |
How the integration tests work โ
The integration tests mount Express route handlers directly (no network) with Firebase Admin mocked:
// Firebase Admin is mocked before imports:
vi.mock('firebase-admin', () => ({ ... }))
// A lightweight fetch-based request helper replaces supertest:
async function request(app, method, path, { body, headers }) {
// Spins up the app on a random port, makes a fetch, shuts down
}
// Fixture tokens map to fixture UIDs:
const ADMIN_TOKEN = 'admin-test-token' // โ uid: 'admin-uid-123'
const USER_TOKEN = 'user-test-token' // โ uid: 'user-uid-456'Adding a test for a new endpoint โ
describe('POST /venues/my-new-endpoint', () => {
let app
beforeEach(() => {
app = createApp()
setupAuth() // registers token โ UID mapping
setupFirestoreUser(USER_UID, 'user') // sets role in mock
})
it('returns 400 for missing required field', async () => {
const res = await request(app, 'POST', '/venues/my-new-endpoint', {
body: {},
headers: { Authorization: `Bearer ${USER_TOKEN}` },
})
expect(res.status).toBe(400)
expect(res.body.error).toBe('VALIDATION_ERROR')
})
})Deploying โ
Never deploy manually. All deployments are triggered automatically by GitHub Actions when code merges to dev (โ dev environment) or main (โ prod).
How deployment works โ
The deploy-dev.yml workflow includes a deploy-venue-api job that:
- Authenticates to GCP using the
GCP_SA_KEY_DEVrepository secret - Runs
gcloud run deploy venue-api --source services/api/venues ... - Cloud Build builds the Docker image from
services/api/venues/Dockerfile - Cloud Run replaces the running revision with the new image
- Verifies the deployment by calling
GET /healthand checking for HTTP 200
Dockerfile โ
The service uses a minimal multi-stage image:
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev # production deps only
COPY src/ ./src/
ENV PORT=8080
USER node # non-root for security
CMD ["node", "src/index.js"]Manual deploy (emergency only) โ
If you need to deploy manually outside of CI (e.g. to fix a critical prod issue), use the script defined in package.json:
cd services/api/venues
npm run deploy:dev
# Runs: gcloud run deploy venue-api --source . --region us-central1 --project lantern-app-dev --allow-unauthenticatedThis requires the gcloud CLI to be authenticated with an account that has Cloud Run Developer permissions on the project.
Verifying a Deployment โ
After a deployment (CI or manual), confirm the service is healthy:
1. Health check โ
curl -s https://venue-api-531553779372.us-central1.run.app/health | jq
# Expected: {"status":"ok","service":"venue-api","timestamp":"..."}2. Check the deployed revision โ
gcloud run services describe venue-api \
--region us-central1 \
--project lantern-app-dev \
--format='value(status.url,status.latestReadyRevisionName)'3. Smoke test an authenticated endpoint โ
TOKEN="<your-admin-token>"
curl -s https://venue-api-531553779372.us-central1.run.app/venues/admin/stats \
-H "Authorization: Bearer $TOKEN" | jq4. Check Cloud Run console โ
Navigate to Cloud Run โ venue-api (dev) to see request counts, error rates, and latency graphs.
Checking Logs โ
Via gcloud CLI โ
# Tail live logs
gcloud logs tail "resource.type=cloud_run_revision AND resource.labels.service_name=venue-api" \
--project lantern-app-dev
# Last 50 entries
gcloud logging read \
"resource.type=cloud_run_revision AND resource.labels.service_name=venue-api" \
--project lantern-app-dev \
--limit=50 \
--format="table(timestamp,severity,textPayload)"Via Google Cloud Console โ
- Open Cloud Logging
- Filter:
resource.type="cloud_run_revision" resource.labels.service_name="venue-api" - Use the severity dropdown to filter by
ERRORorWARNING
What to look for โ
The API uses Pino structured logging. Every request is logged automatically by pino-http. Key log fields:
| Field | Description |
|---|---|
req.method, req.url | HTTP method and path |
res.statusCode | Response status |
responseTime | ms to respond |
userId | Firebase UID (logged explicitly in import/enrich handlers) |
err.message | Error message (production sanitizes stack traces) |
Example: Finding all failed imports
resource.type="cloud_run_revision"
resource.labels.service_name="venue-api"
httpRequest.status>=400
httpRequest.requestUrl=~"/venues/import"Log levels:
- Dev environment:
debug(verbose, includes all request details) - Prod environment:
info(request summaries and explicit log calls only)
Frontend Integration โ
The client-side helper venueApiClient.js wraps all API calls:
import {
importVenuesFromApi,
enrichVenue,
batchRefreshVenues,
geocodeVenue,
getVenueMetadata,
// Admin:
getVenueStats,
deduplicateVenues,
cleanupVenues,
validateVenues,
} from '../lib/venueApiClient'All functions automatically:
- Read the Firebase ID token from
auth.currentUser - Set
Authorization: Bearer <token> - Point at
VITE_VENUE_API_URL(from.env.local) - Throw typed errors with
.statusand.dataon non-2xx responses
Required environment variable โ
# .env.local
VITE_VENUE_API_URL=https://venue-api-531553779372.us-central1.run.app
# or for local dev against a local instance:
VITE_VENUE_API_URL=http://localhost:8080Usage examples โ
// Import venues near the user's location
const result = await importVenuesFromApi(lat, lng, 5000, {
includeGold: true,
includeSilver: true,
includeBronze: false,
})
console.log(`Imported ${result.imported}, skipped ${result.skipped}`)
// Enrich a venue after it's been imported
const enriched = await enrichVenue(venueId)
if (enriched.skipped) {
console.log('Already enriched or no coordinates')
} else {
console.log('Address:', enriched.address)
}
// Admin: get stats
const stats = await getVenueStats()
console.log(`Total venues: ${stats.total}`)Architecture Deep Dive โ
Service files โ
services/api/venues/
โโโ src/
โ โโโ index.js # App setup, CORS, route mounting
โ โโโ middleware/
โ โ โโโ auth.js # Firebase token verification
โ โ โโโ rbac.js # Role-based access (checks Firestore users.role)
โ โ โโโ rateLimiter.js # Sliding window rate limiter
โ โ โโโ errorHandler.js # Global error handler
โ โโโ routes/
โ โ โโโ health.js
โ โ โโโ import.js # /venues/import/*
โ โ โโโ refresh.js # /venues/refresh/*
โ โ โโโ utility.js # /venues/geocode, /venues/:id/metadata, /venues/consolidate
โ โ โโโ admin.js # /venues/admin/*
โ โโโ services/
โ โโโ osm.service.js # Overpass API client, venue building, tier filtering
โ โโโ nominatim.service.js # Nominatim reverse geocoding
โ โโโ venue.service.js # Firestore CRUD, dedup, batch writes
โโโ test/
โ โโโ routes.integration.test.js
โ โโโ osm.service.test.js
โ โโโ nominatim.service.test.js
โ โโโ rateLimiter.test.js
โโโ Dockerfile
โโโ package.json
โโโ vitest.config.jsShared configuration โ
packages/shared/venues/index.js is the single source of truth for constants used by both the API and the frontend. This includes:
- Tier filter definitions (Gold/Silver/Bronze/Coal OSM tags)
- Category mapping (48 OSM amenity/shop/leisure tags โ Lantern categories)
- Overpass API endpoints (3 fallback URLs with retry settings)
- Search term expansions (e.g.,
cafe โ ["coffee", "espresso", "latte"]) - Lifecycle detection patterns (detecting
disused:,abandoned:,was:prefixed tags) - Import defaults (default lat/lng, radius, limits)
- Refresh thresholds (fresh < 14 days, stale 14-30 days, very stale 30+ days)
Import pipeline โ
1. Validate request (Zod)
2. rateLimitMiddleware('import') โ check 5/min per user
3. fetchOSMVenues(lat, lng, radius, options)
a. buildOverpassQuery() โ Overpass QL
b. fetchOverpassWithRetry() โ tries 3 endpoints with exponential backoff
c. Filter results: isLikelyClosed(), isCoalTierTags()
d. buildVenueFromOSM() โ normalizes OSM element to Lantern venue shape
4. importVenuesToFirestore(venues)
a. getExistingOsmIds() โ batch Firestore 'in' query (max 30/batch)
b. Skip venues with known osmIds
c. WriteBatch.set() for new venues (500/batch max)
5. updateRefreshMetadata(lat, lng, result) โ geohash prefix โ lastRefreshedAtCORS โ
The API uses a whitelist of allowed origins:
http://localhost:3001, 3002, 5173, 5174 (local dev)
https://dev.ourlantern.app (dev app)
https://ourlantern.app (prod app)
https://admin.dev.ourlantern.app (dev admin)
https://admin.ourlantern.app (prod admin)Preflight OPTIONS requests return 204 No Content immediately. CORS max-age is 86400 seconds (24 hours).
Request flow through middleware โ
Incoming request
โ
โโโ CORS middleware (all routes)
โโโ express.json({ limit: '1mb' }) โ body parsing
โโโ pino-http โ request/response logging
โ
โโโ GET /health โโโโโโโโโโโโโโโโโโโโโโโโโโโโ No auth โโโบ healthRoutes
โ
โโโ POST /venues/import/* โโโโโโโโโโโโโโโโโโ verifyFirebaseToken
โ rateLimitMiddleware('import')
โ โโโบ importRoutes
โ
โโโ POST /venues/refresh/* โโโโโโโโโโโโโโโโโ verifyFirebaseToken
โ rateLimitMiddleware('refresh'|'enrich')
โ โโโบ refreshRoutes
โ
โโโ GET|POST /venues/* โโโโโโโโโโโโโโโโโโโโโ verifyFirebaseToken
โ โโโบ utilityRoutes
โ
โโโ GET|POST /venues/admin/* โโโโโโโโโโโโโโโ verifyFirebaseToken
requireRole('admin')
โโโบ adminRoutes
โ
errorHandler (last)