carlos / platform Public

docs: headers your app receives; carlos system geoip; operator licence duties (geoip spec §13) [awaiting copy review]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 28ae63593a7b7c30f0d82f25844c6ccdf4012c53 parent a9b6d44
2 files changed, +89 −0
  • README.md +4 −0
  • docs/site/cli.md +85 −0
diff --git a/README.md b/README.md
index 12b34c020..5a3dab297 100644
--- a/README.md
+++ b/README.md
@@ -561,6 +561,10 @@ publish an inflated instance count.
DB-IP Lite is recommended, and it requires the attribution the new-app page
shows. Unset or unusable turns the IP step of the region guess off, and the
console starts either way. Replace the file by atomic rename, then restart.
+ For the platform-wide GeoIP that every edge serves to apps, use
+ `carlos system geoip publish` instead (docs/site/cli.md, "Platform GeoIP").
+ Uploads are staged in `geoip-staging/` next to `CONSOLE_DB`, which the
+ console clears at startup.
**Canonical-host caveat**, stated honestly: an app that pins its own host
(e.g. keymail's `--host keymail.dev`) will redirect or 404 on its platform
diff --git a/docs/site/cli.md b/docs/site/cli.md
index a75e46179..2ad1175c2 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -994,6 +994,47 @@ carlos analytics --app hello --range 7d
visitors summed. The same view is the Analytics tab on the app's dashboard,
and the switch is on the Settings tab too.
+When the deployment has a GeoIP database, page views are also counted by
+country. A visitor the database could not place counts as `unknown`. The
+Analytics tab credits the database the counts came from.
+
+### Headers your app receives
+
+On a deployment with a GeoIP database, every request the edge passes to your
+app carries the visitor's rough location:
+
+| Header | Value |
+|---|---|
+| `X-Carlos-Geo-Country` | the country, as an ISO 3166-1 code such as `IE` |
+| `X-Carlos-Geo-Subdivision` | the state or province, as an ISO 3166-2 code such as `US-WA`. Only some databases have these |
+| `X-Carlos-Geo-Lat`, `X-Carlos-Geo-Lon` | a point, to two decimal places |
+| `X-Carlos-Geo-Source` | which database answered, such as `DBIP-City-Lite` |
+
+Any of them can be missing. They are all missing when the deployment has no
+database, when the database has no answer for the address, and on calls
+through the edge-local door, where the caller is a server rather than a
+visitor. Treat a missing header as "unknown", never as an error.
+
+The point is an area, not a position. These databases place an address to
+the right city at best, and often only to a region or a country.
+
+The edge removes any `X-Carlos-Geo-` header a visitor sends, so the values
+your app sees are always the platform's. The visitor's address is still in
+`X-Forwarded-For` if you need it.
+
+If you cache a page that differs by country, send `Vary:
+X-Carlos-Geo-Country`. The edge keeps at most eight variants of one URL, so a
+page with visitors from many countries will be served from cache less often.
+Don't vary on the coordinates: almost every visitor would get their own
+variant.
+
+**Credit the database.** If your app shows visitors anything worked out from
+these headers, it must credit the database `X-Carlos-Geo-Source` names:
+
+- `DBIP-…`: "IP Geolocation by DB-IP", linked to https://db-ip.com
+- `GeoLite2-…`: "This product includes GeoLite2 data created by MaxMind,
+ available from https://www.maxmind.com"
+
### carlos store
Object storage for an app. An account owner applies once, and a deployment
@@ -1764,6 +1805,50 @@ Deployment operators only (`CARLOS_STORE_OPERATORS`). Enabling scans nothing by
itself — a member still requests scanning for their app and an operator still
grants it per store.
+#### Platform GeoIP
+
+```sh
+carlos system geoip publish dbip-city-lite-2026-10.mmdb
+carlos system geoip status
+carlos system geoip off
+```
+
+`publish` sends a MaxMind-format database, DB-IP Lite or GeoLite2, to the
+console. The console checks it, stores it in the deployment bucket, and every
+edge picks it up within 11 minutes with no restart. From then on, every
+request to every app carries the headers described under
+[Headers your app receives](#headers-your-app-receives), and analytics count
+page views by country. `publish` refuses a file that isn't a valid database,
+has no country data, or is a GeoLite2 database already past its 30 days. It
+also refuses one with under half the current database's nodes, which usually
+means a truncated file. `-force` overrides that last check only.
+
+`status` shows the published database and every box: whether it runs a build
+that removes forged geo headers, and whether it has the current database.
+Its last line says `ready` only when every box does. Publish a database only
+after the whole fleet is ready, and tell apps about the headers only after
+that. Until every box has been updated, an older box passes a visitor's
+forged headers straight through.
+
+A box that has been retired but still has an edges record keeps `status` from
+saying `ready`. Deleting its record (`control/edges/<label>.json`) retires it.
+
+`off` removes the database. Every edge stops sending the headers within 11
+minutes.
+
+**The licence is yours to keep.** DB-IP Lite is licensed under CC BY 4.0
+(https://db-ip.com/db/lite.php), so pages that show its data must credit it.
+The Analytics tab does this for you, and apps are told to do the same.
+GeoLite2 comes under MaxMind's GeoLite EULA
+(https://www.maxmind.com/en/geolite/eula), which also requires replacing the
+database within 30 days of a newer release. The platform enforces that by
+dropping a GeoLite2 database 30 days after it was built. Before you publish
+GeoLite2, apply the deployment-buckets module version that expires deleted
+GeoIP files after a day; the bucket is versioned, and otherwise old copies
+would linger.
+
+Ops account owners and `CARLOS_SYSTEM_OPERATORS`.
+
## On the host
These are what systemd starts. You will rarely type them, but knowing what