# Build your own Mapbox

> Source: https://buildyourown.software/like/mapbox
> Category: Maps API. Original vendor: Mapbox, Inc.. Last updated 2026-09-10.
> This file is written for AI coding agents. It contains the context, the build prompt, and the test plan for a self-built replacement of Mapbox.

## How to use this file

1. Read the whole file first.
2. Build the app described in "Build prompt". Treat its acceptance criteria as the definition of done.
3. When the build works, follow "Test prompt" to write and run automated tests.
4. Ask the user before changing the data model, the stack, or the non-goals.
5. The app must run the "one box" way described in "Default deployment" below: one process, SQLite, local disk, no hosted services unless the build prompt names one.

## What Mapbox 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 disk, MapLibre GL JS draws them, Photon does search, and Valhalla does routing. The meter goes away and you keep the data.

## What it costs

Typical spend: $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.

- Free allowance: $0 per month, every product (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 (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 (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 (Contact sales above 5,000,000 requests a month.)

Prices checked 2026-09-10 at https://www.mapbox.com/pricing.

## Features and whether to build them

- [build] Embedded web map: Vector tiles rendered in the browser with pan, zoom, rotate, markers, and popups. MapLibre GL JS is the open fork of the Mapbox renderer. Point it at a PMTiles file on disk and this part is done.
- [build] Custom basemap styles: Studio lets you recolor and relabel the basemap and publish a style URL. A style is a JSON file. Start from the Protomaps light flavor and edit it in Maputnik.
- [build] Static map images: A URL that returns a PNG of a map with markers, for emails, PDFs, and link previews. Headless Chromium loads your own style, drops markers, and screenshots. Cache the PNG and it costs nothing.
- [build] Forward geocoding: Turn an address or place name into coordinates. Photon with a country extract answers in a few milliseconds and you can store every result.
- [build] Reverse geocoding: Turn coordinates into the nearest address. Same Photon instance, one extra endpoint.
- [build] Address autocomplete: Suggestions as the user types, biased toward a location. This is Mapbox's most expensive product per use. Photon's typeahead does it for one country out of the box.
- [build] Directions: A route between two or more points for driving, cycling, or walking, with distance and duration. 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. Valhalla already exposes both. Wire them up when a feature needs them.
- [maybe] Mobile map SDK: Native map views for iOS and Android. 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. A planet PMTiles build is about 120 GB, which fits on a cheap disk. 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. 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. 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. Licensed imagery. Keep paying if you need it, or use a free government raster layer for your region.

## Under the hood

### Data model

- Tileset: id, slug, name, file_path, 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 under ./data/tiles. 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. Revoking takes effect on the next request; there is no cache in front of the table.
- 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_path, output_path, created_at, finished_at. Batch geocoding a CSV. Files live under ./data/files/geocode_jobs/<id>/. Output keeps row order and adds lon, lat, match_level, matched_address, error.

### Key flows

**Serve a tile**
1. The style points MapLibre at pmtiles://<host>/tiles/<slug>.pmtiles and the pmtiles protocol in the browser takes over.
2. It reads the archive header and directory once, then asks for each tile with an HTTP Range request.
3. Caddy's file_server answers 206 from ./data/tiles with Content-Range, an ETag, and a one-day Cache-Control. No app code runs.
4. Tiles need no key and aren't counted. Metering them would cost more than serving a static file does.

**Count a map load**
1. The page fetches /styles/<style>.json?key=... once when the map initializes.
2. The app checks the key hash, scope, and Origin, then returns the published style with {HOST} replaced by the public URL.
3. After the response is sent it upserts usage_daily for the UTC day with one map_load.
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 croner job in the app process takes the geocode_runner lock, 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 and pushes an SSE event, so the job resumes if the process restarts and the page never polls.
4. The output CSV is written under ./data/files and the dashboard shows a signed 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 under ./data/files/renders, stream 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 writes it to disk and serves it with a seven-day Cache-Control. A daily job prunes PNGs nobody has read in 30 days.

### Integrations

- Caddy (required): TLS on its own, and serves the PMTiles archive from local disk with range requests and cache headers. Zero code.
- SQLite on local disk (required): Keys, usage, styles, tilesets, jobs, and job locks in ./data/app.db. Backed up with one file copy.
- Protomaps build + pmtiles CLI (required): One-time download: extract a regional PMTiles from the daily planet build. Use planetiler if you want to build from a raw OSM PBF instead.
- Photon (Docker) (required): Geocoding, reverse, and autocomplete from a prebuilt country index. One-time download, then local.
- Valhalla (Docker) (required): Routing, matrix, and isochrones from a Geofabrik OSM extract. One-time download, then local.
- Playwright + Chromium (Docker) (required): Renders static map PNGs with your real style and fonts.
- SMTP relay (optional; outbox in dev) (optional): Invites and limit alerts. Without SMTP_URL, mail lands in ./data/outbox as .eml files.
- Nominatim (optional): Swap in for Photon when you need house-number precision or structured queries.
- Maputnik (optional): Visual style editor. Open your style URL in it and paste the JSON back.

## Where to build it

- Claude Code (https://claude.com/claude-code): Best fit. Most of this is infrastructure: a compose file, a Caddyfile, a tile CLI. Claude Code can run docker, curl a byte range off Caddy, hit each endpoint, and fix what breaks.
- Replit (https://replit.com): Fine for building the app and trying it against the sample region. Photon and Valhalla need 8 GB of RAM, so when it works, move the folder to a VPS and run docker compose up -d there.
- ChatGPT / Codex (https://chatgpt.com): Use ChatGPT to pick your region and choose Photon or Nominatim before you start, then hand Codex the spec. Have it build the tile pipeline and style route first since everything else depends on a map on screen.
- Lovable (https://lovable.dev): Skip it, or UI only. Lovable is built around Supabase and this stack has no Postgres, no bucket, and three Docker services. Build the dashboard there if you must and give it the API URLs.

---

## Default deployment

Also at https://buildyourown.software/deploy.md. The build prompt below assumes this.

Every web app on buildyourown.software is written to run the same way. Learn it once and every tool's build prompt makes sense.

## The shape

- **One process.** Node 22 running the app. A second process only when the tool genuinely needs one (a job worker, a probe runner). Never a serverless function.
- **SQLite.** `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations checked into the repo and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- **Local disk for files.** Uploads live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- **In-process scheduler.** `croner` inside the app process for reminders, retention, digests, and polling. A row in a `job_locks` table stops two instances from running the same job.
- **Sessions the app issues itself.** On first run the app creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` (or prompts in the browser if unset). Teammates join through invite links. Sessions are signed, httpOnly cookies. Google sign-in is an optional add-on, never a requirement.
- **Realtime from the process.** Server-sent events fed by an in-memory event bus. No database change feeds, no websocket service.
- **Email through any SMTP server.** `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log, so every flow works on a laptop with no account. When you go public, point `SMTP_URL` at the one relay you choose. Sending straight from a VPS lands in spam, so this is the one hosted service most tools keep.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.

## Running it

- **Laptop:** `pnpm install && pnpm dev`. That's the whole install. No env vars are required; every setting has a default that works locally.
- **Public:** a VPS with 1 CPU and 1 GB RAM, one DNS record, and `docker compose up -d`. The compose file has two services: `app` and `caddy`. Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. A `backup` service in compose runs it nightly. Restoring is copying one file.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## What this rules out

No Vercel, Supabase, Neon, Clerk, Auth0, R2, S3, Fly, Cloudflare Workers, Upstash, or any service that needs an account to run the app. If a tool can't avoid an outside service (a calendar OAuth app, a GitHub webhook, an SMTP relay, a second probe location), the build prompt says so in one line and makes it optional or last.

## Acceptance criteria every tool inherits

- A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
- `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
- Killing the process mid-job and restarting it doesn't double-run or lose the job.
- `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
- Every outbound email in dev shows up in `./data/outbox`.

---

## Build prompt

Also available on its own at https://buildyourown.software/like/mapbox/build-prompt.md

# 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, on one machine you control. 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 Docker Compose file, a CLI for building tiles, a Caddy config, and a thin API in front of two open-source services. The dashboard is small.

## Stack

- **One Node 22 process** running Next.js (App Router), TypeScript, Tailwind. The `/v1/*` API, the dashboard, and the job scheduler all live in it. A second process only for the renderer, which needs its own Chromium.
- **SQLite** through `better-sqlite3` and Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on boot. Keep the schema portable so a Postgres `DATABASE_URL` works later, but don't build for it now.
- **Tiles as a file on disk.** The PMTiles archive lives at `./data/tiles/<slug>.pmtiles`. Caddy serves it with `file_server` (HTTP range requests, ETags, and cache headers are built in, zero code). MapLibre reads it with the `pmtiles` protocol. Sprites and glyphs sit next to it under `./data/assets/`.
- **Local disk for files.** Batch CSVs and rendered PNGs live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself.
- **In-process scheduler.** `croner` inside the app for the batch geocode runner, temp key expiry, render cache pruning, and limit alerts. A `job_locks` row stops two instances from running the same job.
- **Sessions the app issues itself.** First run creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts in the browser if unset. Teammates join through invite links. Signed httpOnly cookies.
- **Realtime** is server-sent events from an in-memory event bus (job progress, usage banner).
- **Email** through `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and logged. Invites, job-done notices, and 80% limit alerts all go through this.
- **Geocoding:** Photon (komoot) in Docker, loaded from a prebuilt country extract. **Routing:** Valhalla in Docker from a Geofabrik PBF. **Renderer:** Node on the Playwright image, one long-lived Chromium, `POST /render`.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.
- **Client demo:** `public/demo.html` with MapLibre GL JS and the `pmtiles` script copied from `node_modules` into `public/vendor/`. No CDN, no SDK of our own.

Full profile: https://buildyourown.software/deploy.md

## Running it

- **Laptop:** `pnpm install && pnpm dev`. No env vars. The dashboard, keys, styles, and tiles work right away; `docker compose up -d photon valhalla renderer` adds geocoding, routing, and static images once their data has downloaded.
- **VPS:** one DNS record, `docker compose up -d`. Services: `caddy`, `app`, `photon`, `valhalla`, `renderer`, `backup`. Caddy gets its own TLS certificate. `./data` is a named volume. Only Caddy is exposed.
- **Size the box honestly.** A country-sized region needs about 8 GB RAM (Valhalla's graph build peaks near 4 GB, Photon holds about 2 GB) and about 20 GB disk for the archive, the index, and the graph. This is not a $5 VPS. The tiny sample region runs on a laptop.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. The `backup` service runs it nightly. Tiles, the Photon index, and the Valhalla graph are rebuildable from the region config, so back up `app.db` and `./data/files` only.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

The only things this stack downloads from outside are one-time data files, like a model download: the Protomaps planet build (or your own planetiler output), a Geofabrik PBF, and the Photon country extract. No accounts anywhere. `SMTP_URL` is optional and only matters when you want invites and alerts to reach a real inbox from a VPS.

## Environment variables

Every one has a default that works on a laptop. Fail fast with a clear message only when a value is set and malformed.

- `DATA_DIR` (default `./data`), `PUBLIC_URL` (default `http://localhost:3000`), `SESSION_SECRET` (generated on first boot and stored at `./data/session-secret` if unset).
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` (if unset, the first browser visit shows a setup form).
- `SMTP_URL` (unset means the outbox), `MAIL_FROM` (default `maps@localhost`).
- `PHOTON_URL` (default `http://localhost:2322`), `VALHALLA_URL` (default `http://localhost:8002`), `RENDERER_URL` (default `http://localhost:3001`). Compose sets these to the service names.
- `STATIC_SERVER_KEY`: a server-scope key the renderer uses to fetch styles. The seed script creates it and writes it to `./data/static-key` if unset.

Never log a full API key or the session 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

SQLite types only: `text`, `integer`, `real`. Ids are uuid strings in `text`. Every timestamp is an ISO 8601 UTC string in `text` (`2026-09-10T14:03:00.000Z`); there is no `timestamptz`, so the app must never write a local-time string. JSON and arrays are `text` holding JSON, parsed at the edge with a zod schema; there is no `jsonb`, so nothing queries inside them. Booleans are `integer` 0 or 1. All tables have `id`, `created_at`, `updated_at` unless noted.

- `tilesets`: `slug` (unique), `name`, `file_path` (e.g. `tiles/new-england.pmtiles`, relative to `DATA_DIR`), `min_lon`, `min_lat`, `max_lon`, `max_lat`, `center_lon`, `center_lat` (`real`), `center_zoom`, `minzoom`, `maxzoom`, `size_bytes` (`integer`), `vector_layers` (json text), `source` (`protomaps:20260901` or `planetiler`), `built_at`. Read bounds, zooms, and layers from the PMTiles header when registering. Never type them by hand.
- `styles`: `slug` (unique), `name`, `tileset_id`, `flavor` (`light` | `dark` | `white` | `grayscale` | `black`), `spec` (json text, a complete MapLibre style with placeholder host `{HOST}` in URLs), `published_spec` (json text, nullable), `published_at` (nullable).
- `style_versions`: `style_id`, `spec`, `created_at`. Keep the last 10 per style.
- `api_keys`: `name`, `prefix` (first 12 chars, for display), `key_hash` (sha256 hex, unique), `allowed_origins` (json text array of glob patterns like `https://*.example.com`; empty means any origin, which is only allowed for server-side scopes), `scopes` (json text array from `tiles`, `geocode`, `route`, `static`), `monthly_limits` (json text: `{ "map_load": 500000, "geocode": 200000, "route": 100000, "static": 50000 }`), `expires_at` (nullable, for temp Maputnik keys), `revoked_at` (nullable), `last_used_at` (nullable).
- `usage_daily`: `api_key_id`, `product` (`map_load` | `tile` | `geocode` | `reverse` | `route` | `static` | `rejected`), `day` (`text`, `YYYY-MM-DD` in **UTC**), `count` (`integer`, 64-bit in SQLite so no `bigint` needed). Unique on (`api_key_id`, `product`, `day`). Upsert with `ON CONFLICT DO UPDATE SET count = count + 1`. No `id`.
- `geocode_jobs`: `api_key_id`, `filename`, `column_map` (json text: either `{ "address": "Full Address" }` or `{ "street": "...", "city": "...", "postcode": "...", "country": "..." }`), `total_rows`, `done_rows`, `failed_rows`, `status` (`queued` | `running` | `done` | `failed`), `input_path`, `output_path` (nullable), `error` (nullable), `finished_at` (nullable). Files live at `./data/files/geocode_jobs/<id>/input.csv` and `output.csv`.
- `users`: `email` (unique), `name`, `password_hash` (argon2), `role` (`admin`), `invited_by` (nullable). `invites`: `email`, `token_hash`, `expires_at`, `accepted_at` (nullable). `sessions`: `user_id`, `token_hash`, `expires_at`.
- `job_locks`: `name` (primary key), `locked_by` (process id plus a random suffix), `locked_at`, `expires_at`. A job runs only if it inserted the row or the existing row's `expires_at` is in the past.

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

- `caddy`: `caddy:2`, ports 80 and 443, volumes `caddy-data` and `./data:/data/maps:ro`. The Caddyfile:
  - `handle_path /tiles/*` and `handle_path /assets/*`: `file_server` from `/data/maps/tiles` and `/data/maps/assets`. Caddy answers `Range` requests with 206, sends `Accept-Ranges: bytes` and an `ETag`, and honors `If-None-Match`. Add `header Cache-Control "public, max-age=86400"` on tiles and `"public, max-age=31536000, immutable"` on assets, `Access-Control-Allow-Origin *` on both, and `header Content-Type application/x-protobuf` for `*.pbf`.
  - Everything else: `reverse_proxy app:3000`.
  - Say in the README why tiles go through Caddy and not the app: zero code, range requests and caching built in, and a static file is the fastest thing a web server does.
- `app`: built from the repo, runs `next start` with the scheduler inside it. Port 3000 on the compose network only. Volume `./data:/app/data`.
- `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`). Port 2322. Healthcheck: `GET /api?q=test` returns 200. 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`. About 4 GB RAM during the build.
- `renderer`: built from `renderer/Dockerfile` on `mcr.microsoft.com/playwright:<pinned>-jammy`. Port 3001. Healthcheck: `GET /healthz`. `shm_size: 1gb`.
- `backup`: an alpine image with `sqlite3`, runs `pnpm backup`'s script nightly at 03:00 UTC against `/app/data`.
- Only `caddy` is exposed. Everything else is 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.

In dev, `pnpm dev` runs without Caddy. The app has one small fallback route for `/tiles/*` and `/assets/*` that streams the file with `Range` support (`createReadStream({ start, end })`, 206, `Content-Range`) so the laptop works alone. In compose, Caddy matches those paths first and the app never sees them.

## 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/tiles/<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:register`: reads the PMTiles header and metadata with the `pmtiles` library's file source and upserts the `tilesets` row: bounds, center, zooms, size, `vector_layers`, source, built_at. No upload step. The file is already where Caddy serves it.
3. `pnpm assets:sync`: copies sprites and glyph PBFs from the `protomaps/basemaps-assets` repo into `data/assets/sprites/v4/<flavor>.*` and `data/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` as `{ type: "vector", url: "pmtiles://{HOST}/tiles/<slug>.pmtiles" }`, `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 backup`: `sqlite3 data/app.db ".backup data/backups/<YYYY-MM-DD>.db"`, then delete all but the newest 14.

## Tiles and styles (served routes)

### Tiles

`GET /tiles/<slug>.pmtiles` is a static file. The browser's `pmtiles` protocol reads the header, the directory, and each tile with `Range` requests; Caddy answers 206 with `Content-Range` and caches nothing itself because it doesn't have to. No key, no counting: tiles are the cheap part, and metering them would cost more than serving them. Missing tiles (outside bounds or past `maxzoom`) are handled by the client library, which returns an empty tile.

### Styles

`GET /styles/:slug.json?key=` (or `Authorization: Bearer`) in the app:

1. Read the key. Missing → 401 `{ "error": "missing_key" }`. Look up `sha256(key)` in `api_keys`. Missing, revoked, or expired → 403 `{ "error": "invalid_key" }`. Revocation is immediate; there is no cache in front of the table.
2. If the request has an `Origin` header and the key has `allowed_origins`, match with globs. No match → 403 `origin_not_allowed`. Requests without an `Origin` (curl, server-side) are allowed only if `allowed_origins` is empty. `tiles` must be in `scopes`.
3. Return `published_spec` with every `{HOST}` replaced by `PUBLIC_URL`, `Cache-Control: private, max-age=300`, `Access-Control-Allow-Origin` set to the request's Origin, and `Vary: Origin`.
4. After the response is sent, record one `map_load` for the UTC day.

A **map load** is one style fetch. Tiles are not counted against limits. This mirrors how Mapbox bills and keeps the metering cheap.

`GET /v1/tilesets/:slug` returns `{ slug, bounds, center, minzoom, maxzoom, vector_layers, url: "pmtiles://<PUBLIC_URL>/tiles/<slug>.pmtiles" }` for the dashboard and docs.

## App API (`/v1/*`)

Auth for every route: `key` query param or `Authorization: Bearer <key>`. Look up by hash. 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. Writes the CSV to `./data/files/geocode_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 (`/files/geocode_jobs/<id>/output.csv?exp=<unix>&sig=<hmac>`, valid 15 minutes, signed with `SESSION_SECRET`).

The batch runner is a `croner` job in the app process, every 5 seconds. It takes the `job_locks` row `geocode_runner` (expiry 60 s, renewed every 20 s while running), picks the oldest `queued` or `running` job, streams the CSV with `csv-parse`, geocodes 8 rows concurrently while preserving output order, and appends to `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` and publishes `job.progress` on the event bus. On restart it counts the lines already in `output.csv`, reconciles with `done_rows`, and skips rows below that. Each row counts as one `geocode` in `usage_daily`. When the job finishes, email the uploader's admin address through nodemailer.

### 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 `./data/files/renders/<hash>.png` exists, stream it. Otherwise `POST <RENDERER_URL>/render` with the params and the style JSON (with `{HOST}` replaced by `PUBLIC_URL` and `STATIC_SERVER_KEY` appended), write 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.
- A daily `croner` job `prune_renders` deletes PNGs not read in 30 days (track `atime`, or a `last_read_at` sidecar).

## 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 and the `pmtiles` protocol bundled locally so renders don't depend on the internet), passes the params with `page.evaluate`. The page registers the `pmtiles` protocol, 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`) and a published copy (`published_spec`). Publish copies the draft after validation. The style route only ever reads `published_spec`.
- Validation runs `validateStyleMin` and also checks that every `source.url`, `sprite`, and `glyphs` value starts with `{HOST}/` or `pmtiles://{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"]` and `expires_at` one hour out. The `expire_temp_keys` job revokes it. Show a note explaining that. Maputnik is a static page on GitHub Pages; you can also run it locally from its Docker image and point it at `localhost`.
- Pasting Maputnik output back replaces the draft. Write the previous draft to `style_versions` and trim to 10 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 and jobs

- One function `recordUsage(apiKeyId, product, at)` does every write. `day = at` truncated to a date **in UTC**. Upsert `usage_daily`. Never use server local time. Every caller runs it after the response is sent.
- `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: 600 requests per minute per key using an in-memory sliding window. Over → 429 `rate_limited`, `Retry-After: 60`.
- Scheduled jobs, all `croner`, all through `job_locks`: `geocode_runner` (every 5 s), `expire_temp_keys` (every minute), `prune_renders` (daily 04:00 UTC), `limit_alerts` (hourly: email once per key per month when any product crosses 80% of its limit; record the send in a `limit_alerts_sent` table so it doesn't repeat).
- `GET /api/events` is an SSE stream for signed-in users. The bus publishes `job.progress`, `job.done`, and `usage.alert`. The Jobs and Overview screens subscribe to it. No polling.

## 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, Valhalla, or the renderer is down, return 503 `upstream_unavailable` with `Retry-After: 5`, and do not count usage for that request. On a laptop before `docker compose up`, this is the normal state, and the Playground says so instead of showing a stack trace.
- 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 job lock whose `expires_at` has passed belongs to a dead process. Take it over and log that you did. A process that loses its lock (renewal fails) stops at the next row boundary.
- SQLite is single-writer. Keep every usage write inside one short transaction and set `busy_timeout` to 5000 so a burst of API calls queues instead of failing with `SQLITE_BUSY`.
- 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 the app's own 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, updated over SSE.
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; it takes effect on the next request.
3. **Tilesets (`/tilesets`).** Rows from the table with bounds drawn on a small map, size, source, built date, and the `pmtiles://` 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, 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 fed by SSE, 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. **Team (`/team`).** Admins, pending invites, and an "Invite" form that emails a link (or shows the outbox path in dev).
8. **Docs (`/docs`).** Generated from one markdown file that documents every `/v1` endpoint, the style route, and the tile file, with a copy-paste MapLibre snippet (including the `pmtiles` protocol registration) using a real key selected from a dropdown.

## Demo page

`public/demo.html`: `vendor/maplibre-gl.js` and `vendor/pmtiles.js`, `maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile)`, `new maplibregl.Map({ container, style: "<PUBLIC_URL>/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, a z/x/y tile route (the `pmtiles` protocol covers web and MapLibre Native), an AI feature of any kind. No hosted services beyond the one-time data downloads (Protomaps build or planetiler output, Geofabrik PBF, Photon country extract) and an optional SMTP relay. Nominatim and OSRM are documented as alternatives. Don't build them. If you're tempted, add a TODO comment.

## Acceptance criteria

1. `pnpm tiles:build && pnpm tiles:register` with the sample region produces `./data/tiles/<slug>.pmtiles` and a `tilesets` row whose bounds, `minzoom`, `maxzoom`, and `vector_layers` match the PMTiles header. `GET /v1/tilesets/<slug>` returns them and a `pmtiles://` URL on `PUBLIC_URL`.
2. `curl -r 0-16383 <PUBLIC_URL>/tiles/<slug>.pmtiles` returns 206 with `Content-Range`, `Accept-Ranges: bytes`, `Cache-Control: public, max-age=86400`, and an `ETag`. Repeating it with `If-None-Match` returns 304. This holds under `pnpm dev` (the fallback route) and under compose (Caddy).
3. A style fetch with no key returns 401. A revoked key returns 403 on the very next request. A valid key with `allowed_origins = ["https://example.com"]` returns 403 when `Origin: https://evil.com` and 200 when `Origin: https://example.com`. Tile range requests need no key.
4. Fetching a style JSON once and then 200 tile range requests produces exactly one `map_load` 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 `PUBLIC_URL`. No request goes to any other domain.
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"`, `usage_daily.geocode` grew by 1,000, and the download link is a signed URL that stops working after 15 minutes. Killing the app 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`), and the file exists under `./data/files/renders/`. The same URL again returns `X-Cache: HIT` in under 100 ms. `@2x` returns a 400 × 400 image. Width 2000 returns 400.
11. Two `recordUsage` calls 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%, and one alert email went out when it crossed 80.
13. `docker compose down && docker compose up -d` keeps the archive, the Photon index, the Valhalla graph, and `app.db`. Nothing re-downloads and the demo page works within a minute.
14. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
15. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
16. Killing the process mid-job and restarting it doesn't double-run or lose the job.
17. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
18. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app, the renderer, `docker-compose.yml`, `Caddyfile`, `scripts/backup.sh`, 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 the admin (from env or the setup form), 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` and `public/vendor/`.
- README with: laptop setup (`pnpm dev`, then which compose services to start and how long their first-start downloads take), VPS setup (DNS, `docker compose up -d`, the 8 GB / 20 GB sizing), why Caddy serves tiles, backup and restore, the three one-time outside downloads and the optional SMTP relay, RAM and disk per region size, and a section titled "When to keep paying Mapbox" that says turn-by-turn navigation, traffic, and satellite imagery.

Build the tile pipeline, the style route, and the demo page first and get a map on screen with `pnpm dev`. Then keys and usage. Then geocoding, routing, static images, the dashboard, and last the compose file with Caddy. Run the demo page in a browser after each step.

---

## Manual test checklist

- [ ] Build a PMTiles for your city, register it, and confirm /v1/tilesets/<slug> reports the right bounds and zoom range.
- [ ] Open the demo page, pan around at zoom 14, and confirm the network tab shows only Range requests to your own domain and nothing to any other host.
- [ ] Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.
- [ ] Revoke a key and confirm the very next style fetch with it gets a 403.
- [ ] 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.

## Test prompt

Also available on its own at https://buildyourown.software/like/mapbox/test-prompt.md

Write automated tests for the self-hosted map stack in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the `/v1` routes, the style route, the tile fallback route, the scheduler, and pure helpers. Use Playwright for the demo page and the pixel checks on static images. Every test file gets its own on-disk SQLite database: create a temp dir, set `DATA_DIR` to it, run the migrations, and delete it in `afterAll`. Never share `app.db` between files and never use `:memory:` (WAL and `busy_timeout` behave differently). 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. Mock only the outside downloads (the Protomaps build, the Geofabrik PBF, the Photon extract) by pointing the scripts at fixture files, and never call a real SMTP server. Freeze time with `vi.useFakeTimers()` wherever a UTC day boundary matters.

## Acceptance criteria to cover

1. After `tiles:register` against the sample archive, the `tilesets` row and `GET /v1/tilesets/<slug>` both report the bounds, `minzoom`, `maxzoom`, and `vector_layers` read from the PMTiles header, and the URL starts with `pmtiles://` on `PUBLIC_URL`.
2. `GET /tiles/<slug>.pmtiles` with `Range: bytes=0-16383` returns 206, `Content-Range`, `Accept-Ranges: bytes`, `Cache-Control: public, max-age=86400`, and an `ETag`; `If-None-Match` with that ETag returns 304; a range past the end returns 416. Run this against the app's fallback route in Vitest and against Caddy in the compose e2e run.
3. A style fetch with no key returns 401. Revoking a key makes the next style fetch return 403 with no delay. `allowed_origins = ["https://example.com"]` gives 403 for `Origin: https://evil.com` and 200 for `Origin: https://example.com`. A tile range request with no key returns 206.
4. One style fetch plus 200 tile range requests 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 equals `PUBLIC_URL`. 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. The download link is signed and returns 403 after advancing fake time 16 minutes. Kill the app 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, pixel (100, 100) has `r > 200, g < 60, b < 60`, and `DATA_DIR/files/renders/<hash>.png` exists afterward. The second request returns `X-Cache: HIT` in under 100 ms. `@2x` yields 400 × 400. Width 2000 returns 400. 51 markers returns 400.
11. `recordUsage` 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. Exactly one `.eml` in the outbox mentions the key crossing 80%.
13. `docker compose -f docker-compose.test.yml down && up -d` keeps the sample archive, the Photon index, the Valhalla graph, and `app.db`; the health checks pass without any download and the demo page works.
14. A fresh clone with no env vars: `pnpm dev` boots, `GET /` redirects to `/setup`, and posting the form creates one `users` row and a session cookie. With `ADMIN_EMAIL` and `ADMIN_PASSWORD` set, the admin exists before the first request.
15. In the compose e2e run, `https://<test host>/` returns 200 with a valid certificate chain (use Caddy's internal CA for the test and trust it in the Playwright context).
16. Job lock: start two app instances against the same `DATA_DIR` with one queued job. Exactly one `job_locks` row for `geocode_runner` exists, only one instance writes rows, and the second logs that it skipped. Kill the holder, advance fake time past `expires_at`, and assert the survivor takes the lock and finishes with 1,000 rows and no duplicates.
17. `pnpm backup` writes `DATA_DIR/backups/<date>.db`; delete `app.db`, copy the backup back, restart, and assert every table has the same row count and the same ids as before. Fifteen backups prune to fourteen.
18. With `SMTP_URL` unset, inviting a teammate and finishing a batch job each write one `.eml` to `DATA_DIR/outbox/` with the right `To:` and a link that starts with `PUBLIC_URL`. With `SMTP_URL` set to a stubbed nodemailer transport, the outbox stays empty and the stub receives both messages.

## 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/sample.pmtiles`: the archive for that bbox, checked in (a few MB), so `tiles:build` never runs in tests.
- `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, a revoked key, and an expired temp 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 copies `fixtures/sample.pmtiles` into the test `DATA_DIR/tiles/` and runs `tiles:register`.
- The app under test runs with `PUBLIC_URL` pointing at itself in Vitest and at Caddy in the e2e run.

## 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 signed URL signer and verifier, the static cache key hash (parameter order must not change the hash).
- `tests/integration/`: the style route, tile fallback, `/v1` routes, scheduler, backup, and mail against a temp SQLite and the test containers, for 1 through 4, 6 through 12, 14, and 16 through 18.
- `tests/e2e/`: Playwright against the compose stack for 5, 13, 15, and the pixel check in 10.

## Rules

- Name every test after its criterion: `test("AC2: range request on the archive returns 206")`.
- 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 the health endpoints, and runs the suite. No services needed beyond Docker on the runner: no accounts, no hosted anything, no secrets in CI.
- 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.
