Maps API · by Mapbox, Inc.
Build your own Mapbox.
Mapbox meters every map load, geocode, and route. MapLibre, PMTiles on R2, Photon, and Valhalla give you the same stack with no per-request bill.
What you're paying now
$10,800 per year
A listings site doing 200,000 web map loads a month ($250 for the 50,001 to 100,000 band plus $400 for the 100,001 to 200,000 band), 300,000 temporary geocodes ($150), and 150,000 directions requests ($100) pays $900 a month, or $10,800 a year.
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
Build my own Mapbox. Fetch https://buildyourown.software/like/mapbox/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.
Claude Code
claude "Build my own Mapbox. Fetch https://buildyourown.software/like/mapbox/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Codex CLI
codex "Build my own Mapbox. Fetch https://buildyourown.software/like/mapbox/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
Mapbox is a set of map APIs. You embed a map with the Maps SDK for web or mobile, style it in Studio, turn addresses into coordinates with Geocoding, draw routes with Directions, and render map PNGs with Static Images. Every product has a free monthly allowance and then bills per 1,000 requests, with the price stepping down as volume goes up.
Some of it is worth paying for. If you need worldwide turn-by-turn navigation on a phone, with live traffic, lane guidance, voice prompts, and rerouting, keep paying. Nobody should build that. Same for satellite imagery, which Mapbox licenses from providers you can't buy from directly.
The common cases are different. Embedding a map on your site, geocoding a customer list, address autocomplete for one country, and a route between two points all run fine on open data. OpenStreetMap tiles go in one PMTiles file on R2, MapLibre GL JS draws them, Photon does search, and Valhalla does routing. The meter goes away and you keep the data.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free allowance
$0
per month, every product
- –50,000 web map loads
- –25,000 mobile monthly active users
- –100,000 temporary Geocoding requests
- –100,000 Directions requests
- –50,000 Static Images requests
Allowances reset monthly. Search Box (500 sessions) and Address Autofill (1,000 sessions) have much smaller free tiers.
Maps
$5.00
per 1,000 web map loads from 50,001 to 100,000 a month
- –$4.00 per 1,000 from 100,001 to 200,000
- –$3.00 per 1,000 from 200,001 to 1,000,000
- –$2.50 per 1,000 from 1,000,001 to 5,000,000
- –Mobile SDKs: $4.00 per 1,000 monthly active users after 25,000
- –Static Images: $1.00 per 1,000 requests after 50,000
Contact sales above 5,000,000 loads. Studio is included, but map seats beyond the first 3 cost $4.00 each per month.
Geocoding
$0.75
per 1,000 temporary requests from 100,001 to 500,000 a month
- –$0.60 per 1,000 from 500,001 to 1,000,000
- –$0.45 per 1,000 above 1,000,000
- –Permanent geocoding (you may store the results): $5.00 per 1,000, $4.00 per 1,000 above 500,000
- –Search Box: $3.00 per 1,000 sessions after 500
- –Address Autofill: $12.50 per 1,000 sessions after 1,000
Permanent geocoding requires contacting sales before you can turn it on.
Directions
$2.00
per 1,000 requests from 100,001 to 500,000 a month
- –$1.60 per 1,000 from 500,001 to 1,000,000
- –$1.20 per 1,000 above 1,000,000
- –Matrix, Isochrone, Map Matching, and Optimization use the same bands (Matrix bills per element)
- –Navigation SDK v3: 100 MAU and 1,000 trips free, then $0.30 per user and $0.08 per trip
Contact sales above 5,000,000 requests a month.
The fine print
- Temporary geocoding results can't be stored. If you geocode a customer list and save the coordinates, you're supposed to be on Permanent at $5.00 per 1,000. That's more than six times the price, and you have to talk to sales first.
- A web map load counts every time a Map object is created. A single-page app that mounts the map on each route change burns several loads on one visitor.
- Address Autofill bills $12.50 per 1,000 sessions after the first 1,000. A checkout form with 20,000 sessions a month costs about $237 for autocomplete alone.
- The Navigation SDK bills per monthly user and per trip on top of Directions. A mobile app with 5,000 active users doing 20,000 trips a month pays $1,470 for users plus $1,520 for trips.
- Every cheaper band needs volume to unlock, and everything past the top band is contact sales. There is no annual discount on the self-serve plan.
- Attribution and the Mapbox logo are required on every map, and the terms forbid caching or bulk downloading tiles. You can't keep a copy of what you paid for.
Prices checked September 10, 2026 at www.mapbox.com. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
7 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Embedded web map
Vector tiles rendered in the browser with pan, zoom, rotate, markers, and popups.
Why: MapLibre GL JS is the open fork of the Mapbox renderer. Point it at a PMTiles file on R2 and this part is done.
- Build it
Custom basemap styles
Studio lets you recolor and relabel the basemap and publish a style URL.
Why: A style is a JSON file. Start from the Protomaps light flavor and edit it in Maputnik.
- Build it
Static map images
A URL that returns a PNG of a map with markers, for emails, PDFs, and link previews.
Why: Headless Chromium loads your own style, drops markers, and screenshots. Cache the PNG and it costs nothing.
- Build it
Forward geocoding
Turn an address or place name into coordinates.
Why: Photon with a country extract answers in a few milliseconds and you can store every result.
- Build it
Reverse geocoding
Turn coordinates into the nearest address.
Why: Same Photon instance, one extra endpoint.
- Build it
Address autocomplete
Suggestions as the user types, biased toward a location.
Why: This is Mapbox's most expensive product per use. Photon's typeahead does it for one country out of the box.
- Build it
Directions
A route between two or more points for driving, cycling, or walking, with distance and duration.
Why: Valhalla in Docker builds a routing graph from an OSM extract in minutes and covers all three profiles.
- Maybe
Matrix and isochrones
Travel time between many points, or the area reachable in N minutes.
Why: Valhalla already exposes both. Wire them up when a feature needs them.
- Maybe
Mobile map SDK
Native map views for iOS and Android.
Why: MapLibre Native reads the same style and tiles. Use it if you already have a native app. A web view is fine otherwise.
- Maybe
Worldwide coverage
Tiles and search for every country.
Why: A planet PMTiles build is about 120 GB and costs under $2 a month on R2. Geocoding the whole planet needs a big machine. Start with your region.
- Skip
Turn-by-turn navigation SDK
In-app navigation with voice, lane guidance, rerouting, and live traffic.
Why: Keep paying. This is years of work and depends on traffic data you don't have.
- Skip
Live traffic and traffic-aware ETAs
Routes that account for current congestion.
Why: Needs a fleet of probes. Valhalla gives free-flow times, which is what most apps show anyway.
- Skip
Satellite imagery
Aerial tiles as a basemap layer.
Why: Licensed imagery. Keep paying if you need it, or use a free government raster layer for your region.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Tileset
id · slug · name · storage_key · bounds (min_lon, min_lat, max_lon, max_lat) · center (lon, lat, zoom) · minzoom · maxzoom · size_bytes · source · built_at
One row per PMTiles file in R2. Read bounds and zooms from the PMTiles header, don't type them.
Style
id · slug · name · tileset_id · flavor (light | dark | white | grayscale | black) · spec (json) · published_at · updated_at
spec is a full MapLibre style document. Validate with the style spec package before saving.
ApiKey
id · name · prefix · key_hash · allowed_origins · scopes · monthly_limits (json) · revoked_at · last_used_at · created_at
Show the key once. Store only the SHA-256. Mirror active keys to Workers KV so the tile edge can check them.
UsageDaily
api_key_id · product (map_load | tile | geocode | reverse | route | static) · day (UTC date) · count
Unique on (api_key_id, product, day). Upsert with count + 1. Days are UTC, and the dashboard says so.
GeocodeJob
id · api_key_id · filename · column_map (json) · total_rows · done_rows · failed_rows · status · input_key · output_key · created_at · finished_at
Batch geocoding a CSV. Output keeps row order and adds lon, lat, match_level, matched_address, error.
Key flows
Serve a tile
- 1.The browser asks the Worker for /tiles/<tileset>/<z>/<x>/<y>.mvt?key=...
- 2.The Worker checks the key hash in KV and the request Origin against the key's allowed origins.
- 3.It looks up the tile in the Cache API using a cache key that strips the key parameter, so all keys share one cache.
- 4.On a miss it reads the PMTiles header and directory (cached in memory), range-reads the tile bytes from R2, and returns them gzip-encoded with a one-day Cache-Control and an ETag.
Count a map load
- 1.The page fetches /styles/<style>.json?key=... once when the map initializes.
- 2.The Worker returns the style with tile, sprite, and glyph URLs rewritten to include the key.
- 3.In the background it posts one map_load event to the app, which upserts usage_daily for the UTC day.
- 4.Tile requests are not counted toward the limit. They're cached and cheap.
Geocode a CSV
- 1.User uploads a CSV and maps columns to street, city, postcode, and country, or picks one full-address column.
- 2.A job worker streams rows in order, queries Photon 8 at a time, and writes lon, lat, match_level, matched_address, and error per row.
- 3.Progress updates done_rows every 100 rows so the job can resume if the worker restarts.
- 4.The output CSV lands in R2 and the dashboard shows a download link and a map of the matched points.
Get a route
- 1.GET /v1/route?from=lon,lat&to=lon,lat&profile=driving validates coordinates and the key.
- 2.The app converts to Valhalla's {lat, lon} objects and costing name, posts to /route, and decodes the precision-6 polyline.
- 3.It returns a GeoJSON LineString, distance_m, duration_s, and a steps array with instructions.
- 4.Usage is counted once per route request, regardless of waypoint count.
Render a static image
- 1.GET /v1/static/lon,lat,zoom/600x400.png?markers=... is hashed to a cache key. If the PNG exists in R2, return it.
- 2.Otherwise the app posts the params to the renderer, which loads a local page with MapLibre, your style, and 14 px circle markers.
- 3.The renderer waits for the map's idle event, screenshots the viewport, and returns the PNG.
- 4.The app stores it in R2 and serves it with a seven-day Cache-Control.
Integrations
- Cloudflare R2 (or S3)requiredStores PMTiles, sprites, glyphs, batch CSVs, and rendered PNGs. Range requests are the whole trick.
- Cloudflare Workers + KVrequiredThe tile edge: key checks, cache, style rewriting. KV holds the mirror of active keys.
- Protomaps builds + pmtiles CLIrequiredExtract a regional PMTiles from the daily planet build. Use planetiler if you want to build from raw OSM.
- Photon (Docker)requiredGeocoding, reverse, and autocomplete from a prebuilt country index.
- Valhalla (Docker)requiredRouting, matrix, and isochrones from a Geofabrik OSM extract.
- Playwright + Chromium (Docker)requiredRenders static map PNGs with your real style and fonts.
- Postgres (Supabase, Neon, or in compose)requiredKeys, usage, styles, tilesets, jobs.
- NominatimoptionalSwap in for Photon when you need house-number precision or structured queries.
- MaputnikoptionalVisual style editor. Open your style URL in it and paste the JSON back.
05
The build prompt
The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# Build a self-hosted map stack (replacing Mapbox for web maps, geocoding, and routing)
You are building a map service that our own apps call instead of Mapbox. It serves vector tiles and a basemap style to MapLibre GL JS, geocodes addresses forward and reverse with autocomplete, computes routes, renders static map PNGs with markers, and meters usage per API key. It covers one region to start (a country or a few states) and can grow to the planet by swapping one file. Everything runs on open data (OpenStreetMap via Protomaps) and open software. Build it end to end. Correctness of coordinates and cache behavior matters more than features.
This is not a CRUD app. Most of the work is a Cloudflare Worker, a Docker Compose file, a CLI for building tiles, and a thin API in front of two open-source services. The dashboard is small.
## Stack
- **Tile edge:** Cloudflare Worker in TypeScript (`wrangler`), with an R2 binding, a KV binding, and the Cache API. Use the `pmtiles` npm package for reading the archive.
- **Storage:** one Cloudflare R2 bucket named `maps`. S3 works too; the only requirement is HTTP range requests. Keep storage access behind one small interface so either works.
- **Tile data:** Protomaps daily planet builds at `https://build.protomaps.com/<YYYYMMDD>.pmtiles`, extracted to a region with `pmtiles extract --bbox`. Offer `planetiler` as an alternative that builds from a raw OSM PBF.
- **Geocoding:** Photon (the komoot project) in Docker, loaded from a prebuilt country extract. Nominatim is the documented alternative for house-number precision; don't build it in v1.
- **Routing:** Valhalla in Docker (`ghcr.io/gis-ops/docker-valhalla/valhalla`), building its graph from a Geofabrik PBF for the same region.
- **Renderer:** a Node service in Docker on the official Playwright image, one long-lived Chromium, exposing `POST /render`.
- **App:** Next.js (App Router), TypeScript, Tailwind, Postgres with Drizzle. Auth is magic-link email for the one or two admins. The `/v1/*` API routes live in this app so they sit next to Photon and Valhalla.
- **Orchestration:** one `docker-compose.yml` with services `app`, `postgres`, `photon`, `valhalla`, `renderer`. The Worker is deployed separately with `wrangler deploy` and runs locally with `wrangler dev`.
- **Client demo:** a static page `public/demo.html` using MapLibre GL JS from a CDN and one style URL. No SDK of our own.
If this repo already has a stack, use it and keep the behavior identical.
## Environment variables
- `DATABASE_URL`, `AUTH_SECRET`, `MAGIC_LINK_FROM`, `RESEND_API_KEY` (or SMTP) for the app.
- `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET=maps`. Use the S3-compatible endpoint from the app and scripts so an S3 bucket is a URL change.
- `CF_API_TOKEN`, `CF_KV_NAMESPACE_ID` so the app can write keys, styles, and tileset metadata to KV.
- `WORKER_PUBLIC_HOST` (e.g. `tiles.example.com`), `INTERNAL_SECRET` (shared with the Worker), `APP_PUBLIC_URL`.
- `PHOTON_URL=http://photon:2322`, `VALHALLA_URL=http://valhalla:8002`, `RENDERER_URL=http://renderer:3001`.
- `STATIC_SERVER_KEY`: a server-scope API key the app uses when the renderer fetches tiles. Created by the seed script.
Fail fast at boot with a clear message if any required variable is missing. Never log a full API key or the internal secret.
## Region config
`config/region.json` drives every data step:
```json
{
"slug": "new-england",
"name": "New England",
"bbox": [-73.73, 40.95, -66.88, 47.46],
"country_code": "us",
"geofabrik": "north-america/us-northeast",
"protomaps_build": "20260901"
}
```
`bbox` is `[min_lon, min_lat, max_lon, max_lat]`. Everything in this project uses **lon, lat** order in arrays, matching GeoJSON and MapLibre. The only places that use lat, lon are the Photon and Valhalla request bodies, and those conversions live in one adapter file each.
## Data model
All tables have `id` uuid, `created_at`, `updated_at` unless noted.
- `tilesets`: `slug` (unique), `name`, `storage_key` (e.g. `tiles/new-england.pmtiles`), `min_lon`, `min_lat`, `max_lon`, `max_lat`, `center_lon`, `center_lat`, `center_zoom`, `minzoom`, `maxzoom`, `size_bytes`, `source` (`protomaps:20260901` or `planetiler`), `built_at`. Read bounds and zooms from the PMTiles header when uploading. Never type them by hand.
- `styles`: `slug` (unique), `name`, `tileset_id`, `flavor` (`light` | `dark` | `white` | `grayscale` | `black`), `spec` (jsonb, a complete MapLibre style with placeholder host `{HOST}` in URLs), `published_at` (nullable).
- `api_keys`: `name`, `prefix` (first 12 chars, for display), `key_hash` (sha256 hex, unique), `allowed_origins` (text[], glob patterns like `https://*.example.com`; empty means any origin, which is only allowed for server-side scopes), `scopes` (text[] from `tiles`, `geocode`, `route`, `static`), `monthly_limits` (jsonb: `{ "map_load": 500000, "geocode": 200000, "route": 100000, "static": 50000 }`), `revoked_at` (nullable), `last_used_at` (nullable).
- `usage_daily`: `api_key_id`, `product` (`map_load` | `tile` | `geocode` | `reverse` | `route` | `static`), `day` (date, **UTC**), `count` (bigint). Unique on (`api_key_id`, `product`, `day`). No `id` needed.
- `geocode_jobs`: `api_key_id`, `filename`, `column_map` (jsonb: either `{ "address": "Full Address" }` or `{ "street": "...", "city": "...", "postcode": "...", "country": "..." }`), `total_rows`, `done_rows`, `failed_rows`, `status` (`queued` | `running` | `done` | `failed`), `input_key`, `output_key` (nullable), `error` (nullable), `finished_at` (nullable).
- `admins`: `email` (unique), `name`.
Key format: `mk_live_` followed by 32 random base62 characters. Generate with `crypto.getRandomValues`. Show the full key once on creation, then only the prefix.
## Docker Compose
- `postgres`: `postgres:16`, volume `pgdata`, healthcheck `pg_isready`.
- `app`: built from the repo, runs `next start` and `pnpm jobs:worker` under one process manager, depends on `postgres` healthy. Port 3000.
- `photon`: an image that downloads the country extract for `country_code` on first start into volume `photon-data` (the `rtuszik/photon-docker` image reads `COUNTRY_CODE`; building from the komoot repo also works). Port 2322. Healthcheck: `GET /api?q=test` returns 200. Needs about 2 GB RAM for one mid-sized country.
- `valhalla`: `ghcr.io/gis-ops/docker-valhalla/valhalla`, mounts `data/valhalla` as `custom_files`, builds tiles on first start (10 to 40 minutes for a US region), volume `valhalla-tiles`. Port 8002. Healthcheck: `GET /status`. Needs about 4 GB RAM during the build.
- `renderer`: built from `renderer/Dockerfile` on `mcr.microsoft.com/playwright:<pinned>-jammy`. Port 3001. Healthcheck: `GET /healthz`. Give it `shm_size: 1gb`.
- Only `app` is exposed to the outside. Photon, Valhalla, and the renderer are reachable on the compose network only.
- `docker-compose.test.yml` overrides the region to the tiny sample so the whole stack starts in a few minutes.
Write down the RAM and disk you measured for the sample region in the README, and give rough numbers for a country and for the planet.
## Tile pipeline (CLI in `scripts/`)
Expose these as `pnpm` scripts. Each prints what it's doing and exits non-zero on failure.
1. `pnpm tiles:build`: reads `config/region.json` and runs `pmtiles extract https://build.protomaps.com/<protomaps_build>.pmtiles data/<slug>.pmtiles --bbox=<bbox>`. Install the `pmtiles` CLI if missing and say how. With `--planetiler`, download the Geofabrik PBF and run planetiler instead, producing the same output path.
2. `pnpm tiles:upload`: uploads `data/<slug>.pmtiles` to R2 at `tiles/<slug>.pmtiles` (multipart, resumable). Then reads the PMTiles header with the `pmtiles` library and upserts the `tilesets` row: bounds, center, zooms, size, source, built_at.
3. `pnpm assets:sync`: copies sprites and glyph PBFs from the `protomaps/basemaps-assets` repo into R2 under `assets/sprites/v4/<flavor>.*` and `assets/fonts/<fontstack>/<range>.pbf`. Include `Noto Sans Regular`, `Noto Sans Medium`, and `Noto Sans Italic`.
4. `pnpm styles:seed`: for each flavor, generates a style with the `layers()` helper from `@protomaps/basemaps`, wraps it in a full style document (version 8, `sources.protomaps` pointing at `{HOST}/tiles/<slug>.json`, `sprite: {HOST}/assets/sprites/v4/<flavor>`, `glyphs: {HOST}/assets/fonts/{fontstack}/{range}.pbf`), validates it with `validateStyleMin` from `@maplibre/maplibre-gl-style-spec`, and upserts a `styles` row per flavor. Attribution text: `© OpenStreetMap contributors, © Protomaps`.
5. `pnpm data:routing`: downloads the Geofabrik PBF for `geofabrik` into `data/valhalla/` so the Valhalla container can build its graph on first start.
6. `pnpm keys:sync`: writes every active key to KV as `key:<key_hash>` → `{ id, allowed_origins, scopes, revoked: false }` and deletes revoked ones. The app also does this on every key create or revoke; the script is for recovery.
## Worker (`worker/`)
Bindings: `MAPS` (R2), `KEYS` (KV), env `APP_URL`, `INTERNAL_SECRET`, `PUBLIC_HOST`.
### Routes
- `GET /tiles/:tileset/:z/:x/:y.mvt?key=`
- `GET /tiles/:tileset.json?key=` (TileJSON)
- `GET /styles/:style.json?key=`
- `GET /assets/*` (sprites and glyphs, no key needed)
- `OPTIONS *` for CORS
### Key check
1. Read `key` from the query string. Missing → 401 `{ "error": "missing_key" }`.
2. `sha256(key)` and look up `key:<hash>` in KV. Missing or `revoked` → 403 `{ "error": "invalid_key" }`.
3. If the request has an `Origin` header and the key has `allowed_origins`, match with globs. No match → 403 `{ "error": "origin_not_allowed" }`. Requests without an `Origin` (curl, server-side) are allowed only if `allowed_origins` is empty.
4. `tiles` must be in `scopes`.
5. Set `Access-Control-Allow-Origin` to the request's Origin when allowed, plus `Vary: Origin`.
KV reads are cached per isolate for 60 seconds, so revocation takes effect within about a minute. That delay is intended.
### Tiles
1. Validate `z` in `[0, 22]`, `x` and `y` in `[0, 2^z)`. Otherwise 400.
2. Build a cache key from the URL **without** the `key` parameter, so every API key shares the same cached tiles. Check `caches.default`. On hit, return it with `X-Cache: HIT`.
3. On miss, open the archive with a `Source` that does `MAPS.get(storage_key, { range: { offset, length } })`. Keep the `PMTiles` instance and its directory cache in a module-level `Map` keyed by tileset slug so directories aren't re-read on every request.
4. If the tile isn't in the archive (outside bounds or beyond maxzoom), return 204 with the same cache headers. MapLibre treats 204 as an empty tile.
5. Otherwise return the raw bytes without decompressing, with `Content-Type: application/vnd.mapbox-vector-tile`, `Content-Encoding: gzip` when the header's `tile_compression` says gzip, `Cache-Control: public, max-age=86400, s-maxage=604800`, `ETag: "<tileset>-<built_at epoch>-<z>-<x>-<y>"`, and `X-Cache: MISS`. Honor `If-None-Match` with 304.
6. `ctx.waitUntil(caches.default.put(cacheKey, response.clone()))`.
Copy the structure of Protomaps' own Cloudflare Worker (`serverless/cloudflare` in the `protomaps/PMTiles` repo) for the range source and the gzip pass-through.
### Errors and logging
- Every error is JSON `{ "error": "<snake_case>", "message": "<human>" }` with the right status. Never return an HTML error page from the Worker.
- Log one line per request with method, path, status, tileset, cache status, and elapsed ms. Never log the key itself; log the key prefix.
- If R2 returns an error, respond 502 `storage_error` and do not cache the response.
- A request for a tileset slug that isn't in KV returns 404 `unknown_tileset`.
### TileJSON
`GET /tiles/:tileset.json` returns `{ tilejson: "3.0.0", tiles: ["https://<PUBLIC_HOST>/tiles/<slug>/{z}/{x}/{y}.mvt?key=<key>"], bounds, center, minzoom, maxzoom, attribution, vector_layers }`. Read `vector_layers` from the PMTiles metadata. Cache for 5 minutes. Bounds come from a `tileset:<slug>` KV entry that `tiles:upload` writes, so the Worker never talks to Postgres.
### Styles
`GET /styles/:style.json` reads `style:<slug>` from KV (the app writes it on publish), replaces every `{HOST}` with `https://<PUBLIC_HOST>`, appends `?key=<key>` to the `sources.*.url` entries, and returns it with `Cache-Control: private, max-age=300`. Then, in `ctx.waitUntil`, `POST <APP_URL>/v1/internal/usage` with header `x-internal-secret` and body `{ "api_key_id", "product": "map_load", "count": 1, "at": "<ISO UTC>" }`.
A **map load** is one style fetch. Tiles are not counted against limits. This mirrors how Mapbox bills and keeps the metering cheap.
### Assets
`GET /assets/*` streams from R2 with `Cache-Control: public, max-age=31536000, immutable` and the right content type (`application/x-protobuf` for glyphs, `image/png` and `application/json` for sprites).
## App API (`/v1/*`)
Auth for every route: `key` query param or `Authorization: Bearer <key>`. Look up by hash in Postgres. Check scope. Check the monthly limit for the product (sum of `usage_daily` for the current UTC month). Over the limit → 429 with `Retry-After` set to seconds until the first of next month, UTC. Then do the work, then upsert `usage_daily` (`count = count + 1`) and stamp `last_used_at`. Errors are always `{ "error": "<snake_case>", "message": "<human>" }`.
Coordinate validation everywhere: lon in `[-180, 180]`, lat in `[-90, 90]`, both finite numbers. Anything else → 400 `invalid_coordinates`. Parse `lon,lat` pairs strictly: two numbers, one comma.
### Geocoding
- `GET /v1/geocode?q=<text>&limit=5&lang=en&proximity=lon,lat&bbox=min_lon,min_lat,max_lon,max_lat&country=us`
Calls Photon `GET /api?q&limit&lang&lat&lon&bbox`. Photon wants `lat` and `lon` as separate params and `bbox` in the same lon,lat order we use. Returns a GeoJSON `FeatureCollection` where each feature has `geometry.coordinates` as `[lon, lat]` and `properties`: `label` (one-line address built from house number, street, city, postcode, country), `match_level` (Photon's `type`: `house` | `street` | `locality` | `district` | `city` | `county` | `state` | `country`), `osm_id`, `osm_type`, and the raw address parts.
- `GET /v1/reverse?lon=&lat=&lang=en` calls Photon `/reverse?lat&lon&limit=1`. Same response shape. Empty result → 404 `no_result`.
- Autocomplete is the same `/v1/geocode` with `limit=5`. Add `Cache-Control: public, max-age=3600` to geocode responses so a browser typing the same prefix twice doesn't hit Photon twice.
- `POST /v1/geocode/batch` (multipart: `file`, `column_map` JSON). Limit 50,000 rows. Stores the CSV in R2 at `jobs/<id>/input.csv`, creates a `geocode_jobs` row, returns `{ id }`. `GET /v1/geocode/batch/:id` returns status and, when done, a signed download URL.
The batch runner is `pnpm jobs:worker`, a long-running process in the `app` container. It polls for `queued` jobs every 5 seconds, streams the CSV with `csv-parse`, geocodes 8 rows concurrently while preserving output order, and writes `jobs/<id>/output.csv` with all original columns plus `lon`, `lat`, `match_level`, `matched_address`, `error`. Unmatched rows get empty `lon` and `lat` and `error = "no_match"`. Every 100 rows it updates `done_rows` and `failed_rows`. On restart it skips rows below `done_rows`. Each row counts as one `geocode` in `usage_daily`.
### Routing
- `GET /v1/route?from=lon,lat&to=lon,lat&via=lon,lat;lon,lat&profile=driving|cycling|walking&alternatives=0`
Maps `profile` to Valhalla costing `auto` | `bicycle` | `pedestrian`. Posts to Valhalla `/route` with `locations: [{ lat, lon }, ...]` (the one place we flip order), `costing`, `units: "kilometers"`, `directions_options: { language: "en-US" }`. Decodes each leg's `shape` with polyline precision **6** (Valhalla's default; precision 5 will put the route in the ocean). Returns `{ geometry: <GeoJSON LineString>, distance_m, duration_s, legs: [{ distance_m, duration_s, steps: [{ instruction, distance_m, duration_s, begin_index }] }] }`. Max 25 locations total. Valhalla 400 (no route) → 404 `no_route`.
- `POST /v1/matrix` body `{ sources: [[lon,lat],...], targets: [[lon,lat],...], profile }` → Valhalla `/sources_to_targets`. Returns `{ durations_s: number[][], distances_m: number[][] }`. Max 50 × 50.
- `GET /v1/isochrone?center=lon,lat&minutes=15,30&profile=` → Valhalla `/isochrone` with `polygons: true`. Returns the GeoJSON polygons.
Matrix and isochrone are small. Build them last.
### Static images
`GET /v1/static/<lon>,<lat>,<zoom>/<width>x<height>[@2x].png?style=<slug>&markers=<lon>,<lat>[,<hex>][;...]&key=`
and `GET /v1/static/auto/<width>x<height>[@2x].png?markers=...&padding=40`
- Limits: width and height in `[1, 1280]`, zoom in `[0, 20]`, at most 50 markers, `@2x` doubles the device scale factor. Colors are 6-hex without `#`, default `e11d48`.
- `auto` fits the bounds of all markers with `padding` pixels using MapLibre's `fitBounds` in the renderer. One marker → zoom 14. Clamp lat to `±85.0511` before any tile math.
- Cache key: sha256 of the canonical parameter string. If `renders/<hash>.png` exists in R2, stream it. Otherwise `POST http://renderer:3001/render` with the params and the style JSON (with `{HOST}` replaced by the Worker host and a server-side key appended), store the PNG, and return it. Headers: `Content-Type: image/png`, `Cache-Control: public, max-age=604800`, `X-Cache: HIT|MISS`.
- Counts as one `static` per request, cache hit or not.
## Renderer service (`renderer/`)
- Express or Hono on port 3001. On boot, launch one Chromium and one `BrowserContext`. Reuse them. Restart Chromium if it crashes.
- `POST /render` body `{ style: <style JSON>, center: [lon, lat] | null, zoom, bounds: [[lon,lat],[lon,lat]] | null, padding, width, height, scale, markers: [{ lon, lat, color }] }`.
- Opens a page at `width × height` with `deviceScaleFactor: scale`, loads `renderer/page.html` (MapLibre GL JS bundled locally so renders don't depend on the internet), passes the params with `page.evaluate`. The page creates the map with `interactive: false`, `attributionControl: false`, `preserveDrawingBuffer: true`, adds each marker as a `maplibregl.Marker` with a custom element: a 14 px circle in the given color, 2 px white border, anchored at `center`. Then it waits for `map.once('idle')` and resolves.
- Screenshot the viewport as PNG and return it. Timeout 15 seconds → 504 `render_timeout`.
- Draw attribution as a 10 px text overlay in the bottom-right: `© OpenStreetMap contributors`.
- Because markers are circles anchored at center, a marker placed at the map center is exactly at pixel `(width/2, height/2)`. Tests depend on this.
## Style editing rules
- A style has a draft (`spec` in Postgres) and a published copy (`style:<slug>` in KV). Publish copies the draft to KV after validation. The Worker only ever reads KV.
- Validation runs `validateStyleMin` and also checks that every `source.url`, `sprite`, and `glyphs` value starts with `{HOST}/`. Reject anything pointing at another host so a style can't quietly load tiles from Mapbox.
- Every layer's `source-layer` must exist in the tileset's `vector_layers`. Warn on unknown ones, don't block.
- "Open in Maputnik" mints a temporary browser key scoped to `tiles` with `allowed_origins = ["https://maputnik.github.io"]` that the app revokes after one hour. Show a note explaining that.
- Pasting Maputnik output back replaces the draft. Keep the previous 10 drafts in a `style_versions` table (`style_id`, `spec`, `created_at`) so a bad paste can be undone.
- The default flavors ship with `lang: "en"`. Expose a per-style `lang` field that regenerates label expressions when changed.
## Usage metering
- `POST /v1/internal/usage` (header `x-internal-secret` must match) accepts `{ api_key_id, product, count, at }`. `day = at` truncated to a date **in UTC**. Upsert `usage_daily`. Never use server local time.
- The `/v1/*` middleware writes usage the same way, after the response is sent (don't make callers wait on it).
- `GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD` (requires an admin session; API keys can't call it) returns rows grouped by key, product, and day. Dates are UTC days.
- Per-key burst limit in the app: 600 requests per minute per key using a sliding window in Postgres (or in-memory if single instance). Over → 429 `rate_limited`, `Retry-After: 60`.
## Edge cases to handle
- Longitude exactly `180` or `-180` is valid. A bbox that crosses the antimeridian (`min_lon > max_lon`) is rejected with 400 `bbox_crosses_antimeridian` in v1; document it.
- Latitude above `85.0511` is valid input for geocoding and routing but must be clamped before Web Mercator tile math in the static renderer.
- `lon,lat` parsing: trim whitespace, reject `NaN`, `Infinity`, empty parts, and more than two parts. `-0` is fine.
- A key sent with surrounding whitespace or as `Bearer <key>` with two spaces should still match after trimming.
- CSV inputs may have a UTF-8 BOM, quoted commas, Windows line endings, and blank trailing lines. Handle all four. A blank address row is written back with `error = "empty_input"` so row counts match. Don't skip it.
- Photon returns nothing for an empty or one-character `q`. Return an empty `FeatureCollection` with 200, don't call Photon.
- If Photon or Valhalla is down, return 503 `upstream_unavailable` with `Retry-After: 5`, and do not count usage for that request.
- Duplicate markers at the same point render once. Markers outside the viewport are still included in `auto` bounds.
- Two identical static requests in flight at the same time must not both call the renderer. Use a per-hash in-process lock.
- The usage upsert for the 101st request must happen even though the response is 429, so the dashboard can show attempts. Count it under a separate product `rejected`.
- A style published while a tile request is in flight must not break the request. Styles and tiles are independent.
- Month rollover: a request at `2026-09-30T23:59:59Z` counts toward September, `2026-10-01T00:00:00Z` toward October, in every server timezone.
## Screens (admin, behind magic-link login)
1. **Overview (`/`).** This month's usage per product (UTC month, and the label says "UTC"), a bar chart per day, and the tilesets and styles currently published. A banner when any key is above 80% of a monthly limit.
2. **API keys (`/keys`).** List with prefix, name, scopes, origins, last used, this month's counts. Create modal: name, scopes, origins (one per line), monthly limits. Shows the full key once with a copy button. Revoke with confirmation. Both create and revoke sync KV immediately.
3. **Tilesets (`/tilesets`).** Rows from the table with bounds drawn on a small map, size, source, built date, and the TileJSON URL. A "how to add a region" panel that prints the exact `pnpm` commands.
4. **Styles (`/styles`, `/styles/[slug]`).** List by flavor. Detail: a live MapLibre preview on the left, the JSON on the right in a code editor with validation errors inline, and buttons: Save draft, Publish (writes to KV), Open in Maputnik (opens `https://maputnik.github.io/editor/?style=<style URL with a temporary key>`), Duplicate.
5. **Geocoding jobs (`/jobs`).** Upload form with column mapping (auto-detect columns named like `address`, `street`, `city`, `zip`, `postcode`, `country`), progress bars, download link, and a map of matched points when done.
6. **Playground (`/playground`).** Tabs for Geocode, Reverse, Route, Static. Each shows the request URL as you fill the form, the raw JSON response, and the result on a map. Reverse lets you click the map to set `lon,lat`.
7. **Docs (`/docs`).** Generated from one markdown file that documents every `/v1` endpoint and the Worker routes, with a copy-paste MapLibre snippet using a real key selected from a dropdown.
## Demo page
`public/demo.html`: MapLibre GL JS from a CDN, `new maplibregl.Map({ container, style: "https://<host>/styles/light.json?key=<key>", center: [<region center>], zoom: 12 })`, a geocoder input that calls `/v1/geocode` with debounce 150 ms, and a "route from here to there" button that draws `/v1/route` as a line layer. This page is how you check the whole thing works.
## Non-goals
Do not build: turn-by-turn navigation, live traffic, satellite imagery, a mobile SDK, multi-tenant accounts, billing or Stripe, planet-wide geocoding, an AI feature of any kind. Nominatim, OSRM, and S3 support are documented as alternatives. Don't build them. If you're tempted, add a TODO comment.
## Acceptance criteria
1. `pnpm tiles:build && pnpm tiles:upload` with the sample region produces a `.pmtiles` in R2 and a `tilesets` row whose bounds, `minzoom`, and `maxzoom` match the PMTiles header. `GET /tiles/<slug>.json` returns TileJSON with those bounds and a `tiles` URL that includes the key.
2. A tile request with a valid key returns 200, `Content-Type: application/vnd.mapbox-vector-tile`, `Content-Encoding: gzip`, `Cache-Control: public, max-age=86400, s-maxage=604800`, an `ETag`, and `X-Cache: MISS`. The same request again returns `X-Cache: HIT`. A request with a different valid key also returns `X-Cache: HIT`.
3. A tile request with no key returns 401. A revoked key returns 403 within 60 seconds of revocation. A valid key with `allowed_origins = ["https://example.com"]` returns 403 when `Origin: https://evil.com` and 200 when `Origin: https://example.com`. A tile outside the tileset's bounds returns 204.
4. Fetching a style JSON once and then 200 tiles produces exactly one `map_load` row increment and zero `tile` increments against limits.
5. `public/demo.html` renders the region at zoom 12 through 16 with labels and road names, and the browser makes network requests only to the Worker host and the app host. No request goes to any third-party domain except the MapLibre CDN script.
6. `GET /v1/geocode?q=<a known street address in the region>` returns a `FeatureCollection` whose first feature has `geometry.coordinates` as `[lon, lat]` within 100 m of the true location and `match_level = "house"`. With `bbox` set to a box that excludes that address, it's not in the results. With `proximity` set near a duplicate street name in another town, that town's result comes first.
7. `GET /v1/reverse?lon=&lat=` for a point in the region returns the nearest address. `lat=95` returns 400 `invalid_coordinates`. `lon=lat` swapped (a point in the ocean) returns 404 `no_result` and does not crash.
8. A 1,000-row batch job finishes, the output CSV has exactly 1,000 rows in the original order with all original columns plus the five new ones, rows that can't match have empty `lon` and `lat` and `error = "no_match"`, and `usage_daily.geocode` grew by 1,000. Killing the worker at row 500 and restarting finishes the job without duplicating rows.
9. `GET /v1/route` between two points in the region returns a GeoJSON `LineString` whose first coordinate is within 50 m of `from` and last is within 50 m of `to`, with positive `distance_m` and `duration_s`. `profile=walking` returns a longer `duration_s` than `profile=driving` for a 5 km trip. A `to` point in the ocean returns 404 `no_route`.
10. `GET /v1/static/<center>,14/200x200.png?markers=<center>,ff0000` returns a 200 × 200 PNG whose pixel at (100, 100) is red (`r > 200, g < 60, b < 60`). The same URL again returns `X-Cache: HIT` in under 100 ms. `@2x` returns a 400 × 400 image. Width 2000 returns 400.
11. Two `/v1/internal/usage` posts with `at = "2026-09-10T23:59:30Z"` and `at = "2026-09-11T00:00:30Z"` create rows for `2026-09-10` and `2026-09-11` regardless of the server's `TZ`. The Overview shows both under the right day.
12. A key with `monthly_limits.geocode = 100` gets 200 for the first 100 geocodes of the UTC month and 429 with a correct `Retry-After` on the 101st. The Overview shows the key at 100%.
13. `docker compose up` plus `wrangler dev` brings up the whole stack locally, the demo page works against it, and the README explains every environment variable and the production deploy for the Worker and the compose host.
14. Creating a key makes it work at the Worker within 60 seconds. Revoking it stops it within 60 seconds. `pnpm keys:sync` after clearing KV restores every active key.
## Deliverables
- The Worker, the app, the renderer, `docker-compose.yml`, migrations, and the `scripts/` CLI.
- `config/region.json` for a small sample region that builds in under 10 minutes on a laptop, plus instructions for swapping to your own.
- A seed script that creates one admin, two keys (one browser key with origins, one server key), publishes the `light` and `dark` styles, and queues a 50-row sample geocode job from `fixtures/addresses.csv`.
- `public/demo.html`.
- README: R2 bucket and KV namespace setup, `wrangler.toml`, how the Photon and Valhalla containers get their data on first start, RAM and disk needs per region size, and a section titled "When to keep paying Mapbox" that says turn-by-turn navigation, traffic, and satellite imagery.
Build the Worker and the tile pipeline first and get the demo page showing a map. Then keys and usage. Then geocoding, routing, static images, and finally the dashboard. Run the demo page in a browser after each step.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. Most of this is infrastructure: a Worker, a compose file, a tile CLI. Claude Code can run wrangler and docker, hit each endpoint, and fix what breaks.
- ReplitBuild and host in the browser
Runs the dashboard and the /v1 API fine. Photon and Valhalla need real RAM and disk, so put those on a small VPS and point Replit at them.
- ChatGPT / CodexPlan, then build with Codex
Use ChatGPT to pick your region and choose Photon or Nominatim before you start, then hand Codex the spec. Have it build the Worker first since everything else depends on tiles.
- LovablePrompt-to-app with Supabase built in
Only for the dashboard and playground UI. The Docker services and the Worker won't fit, so build those elsewhere and give Lovable the API URLs.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Build a PMTiles for your city, upload it, and confirm /tiles/<slug>.json reports the right bounds and zoom range.
- Open the demo page, pan around at zoom 14, and confirm the network tab shows requests only to your Worker's host.
- Request the same tile twice and confirm the second response says it came from cache.
- Revoke a key and confirm tile requests with it get a 403 within a minute.
- Reload the demo page ten times and confirm the dashboard shows ten map loads and no tile counts.
- Geocode your office address and check the pin sits on the right building.
- Type the first five letters of a street name in the playground and confirm suggestions appear in under 200 ms.
- Reverse geocode a point in the middle of a park and confirm you get the nearest address instead of an error.
- Upload a 500-row CSV of addresses and confirm the output keeps row order and flags the rows it couldn't match.
- Ask for a driving route and a walking route between the same two points and confirm the walking one avoids the highway.
- Request a static image with one marker at the center and confirm the marker sits in the middle of the PNG.
- Make a request at 23:58 UTC and one at 00:02 UTC and confirm they land on different days in the dashboard.
- Open the style in Maputnik, change the water color, save it back, and confirm the live map updates after a refresh.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the self-hosted map stack in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the Worker (via `@cloudflare/vitest-pool-workers` with Miniflare R2 and KV), the `/v1` routes, and pure helpers. Use Playwright for the demo page and the pixel checks on static images. Run Photon, Valhalla, and the renderer as real containers from `docker-compose.test.yml` using the small sample region; mock nothing that has a container. Freeze time with `vi.useFakeTimers()` wherever a UTC day boundary matters.
## Acceptance criteria to cover
1. After `tiles:upload` against the sample region, the `tilesets` row and `GET /tiles/<slug>.json` both report the bounds, `minzoom`, and `maxzoom` read from the PMTiles header, and the `tiles` URL contains the key.
2. A valid tile request returns 200 with `Content-Type: application/vnd.mapbox-vector-tile`, `Content-Encoding: gzip`, `Cache-Control: public, max-age=86400, s-maxage=604800`, an `ETag`, and `X-Cache: MISS`; the same request returns `X-Cache: HIT`; a second valid key for the same tile also returns `X-Cache: HIT`; `If-None-Match` with the ETag returns 304.
3. No key returns 401. A revoked key returns 403 after the KV cache window (advance fake time 60 s). `allowed_origins = ["https://example.com"]` gives 403 for `Origin: https://evil.com` and 200 for `Origin: https://example.com`. A tile outside bounds returns 204. `z = 23` and `x = 2^z` return 400.
4. One style fetch plus 200 tile fetches increments `usage_daily` for `map_load` by exactly 1 and never writes a `tile` row that counts against limits.
5. The demo page loads and renders labels at zoom 12 and 16; every network request host is the Worker host, the app host, or the MapLibre CDN. Assert on the captured request list.
6. Geocoding a known house address returns `[lon, lat]` within 100 m of the fixture's truth and `match_level = "house"`. A `bbox` that excludes it removes it. `proximity` near the duplicate-name town moves that town's result to index 0.
7. Reverse for a fixture point returns the fixture's street. `lat = 95` returns 400 `invalid_coordinates`. Swapped lon/lat (ocean) returns 404 `no_result`. `lon = "abc"` returns 400.
8. A 1,000-row batch job (fixture `fixtures/addresses-1000.csv`, with 50 rows that can't match) produces an output CSV with 1,000 rows in original order, all original columns plus `lon`, `lat`, `match_level`, `matched_address`, `error`, 50 rows with `error = "no_match"` and empty coordinates, and a `geocode` usage increase of 1,000. Kill the worker process after `done_rows >= 400`, restart it, and assert the output still has exactly 1,000 rows with no duplicates.
9. A route between two fixture points returns a `LineString` whose endpoints are within 50 m of `from` and `to`, positive `distance_m` and `duration_s`, and at least one step. `walking` duration exceeds `driving` duration for the 5 km fixture. An ocean `to` returns 404 `no_route`. Decoding uses precision 6: assert the first coordinate is inside the region bbox.
10. `/v1/static/<center>,14/200x200.png?markers=<center>,ff0000` is a 200 × 200 PNG and pixel (100, 100) has `r > 200, g < 60, b < 60`. The second request returns `X-Cache: HIT` in under 100 ms. `@2x` yields 400 × 400. Width 2000 returns 400. 51 markers returns 400.
11. Posting usage with `at = "2026-09-10T23:59:30Z"` and `at = "2026-09-11T00:00:30Z"` yields rows on `2026-09-10` and `2026-09-11`. Run this test twice, once with `TZ=America/Los_Angeles` and once with `TZ=Asia/Tokyo`, and assert identical rows.
12. A key with `monthly_limits.geocode = 100` returns 200 for 100 requests and 429 on the 101st with `Retry-After` equal to the seconds until the first of next month at 00:00 UTC under fake time. Advancing fake time into the next month makes request 102 return 200.
13. `docker compose -f docker-compose.test.yml up` plus `wrangler dev` results in a green `pnpm test`; the CI workflow does exactly this.
14. Creating a key and calling the Worker within 60 s succeeds; revoking it and advancing 60 s produces 403; deleting every KV entry and running `keys:sync` restores every active key and no revoked one.
## Fixtures
- `fixtures/region.sample.json`: a tiny bbox (one small town) so Photon and Valhalla start fast. Every coordinate fixture below lives inside it.
- `fixtures/addresses.json`: 10 house addresses with true `[lon, lat]`, plus one street name that exists in two towns for the proximity test.
- `fixtures/addresses-1000.csv`: 950 matchable rows and 50 junk rows, with a UTF-8 BOM, quoted commas, and CRLF line endings on purpose.
- `fixtures/routes.json`: a 5 km driving pair, a walking pair, and one pair with `to` in the ocean.
- `fixtures/valhalla-shape.json`: one real Valhalla `shape` string and its decoded coordinates, for the polyline6 unit test.
- `fixtures/keys.json`: a browser key with origins, a server key with no origins, and a revoked key, all with known hashes.
## Container setup
- `docker-compose.test.yml` uses the sample region and named volumes so a second run doesn't rebuild Photon or Valhalla.
- `tests/setup/global.ts` waits for `GET /api?q=test` on Photon, `GET /status` on Valhalla, and `GET /healthz` on the renderer, up to 10 minutes, then uploads the sample `.pmtiles` to Miniflare R2 and seeds KV.
- The Worker runs under Miniflare for the worker tests and under `wrangler dev` for the e2e tests. The app points at whichever is running through `WORKER_PUBLIC_HOST`.
## Layout
- `tests/unit/`: pure helpers. `parseLonLat`, `validateCoords`, bbox parsing, the polyline6 decoder (assert on a known Valhalla shape string), the UTC day truncation, the `Retry-After` calculation, the origin glob matcher, the static cache key hash (parameter order must not change the hash).
- `tests/worker/`: Miniflare tests for criteria 1, 2, 3, 4, 14. Seed R2 with the sample `.pmtiles` and KV with two keys.
- `tests/integration/`: `/v1` routes against real Postgres and the test containers for 6, 7, 8, 9, 10, 11, 12.
- `tests/e2e/`: Playwright for 5 and the pixel check in 10.
## Rules
- Name every test after its criterion: `test("AC2: second tile request is a cache hit")`.
- Coordinates in fixtures are `[lon, lat]`. Add a unit test that fails if any fixture has `|first| > 90 && |second| <= 90` swapped by mistake.
- Never depend on the real clock or the machine's timezone. Inject `now` or use fake timers.
- Add `pnpm test` and a GitHub Actions workflow that starts the compose test stack, waits for Photon and Valhalla health endpoints, runs `wrangler dev` in the background, and runs the suite.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what changed.Mapbox is a trademark of Mapbox, Inc.. This page is independent research and is not affiliated with or endorsed by Mapbox, Inc.. Last updated 2026-09-10.