carlos / platform Public

geoip: pointer, D1 expiry, attribution and the header vocabulary (geoip spec §3, §7, §8.1a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7d04b539ff4402dd355845a52e3dacf2dc6eafbd parent 713984d
4 files changed, +435 −0
  • internal/geoip/answer.go +173 −0
  • internal/geoip/answer_test.go +81 −0
  • internal/geoip/pointer.go +110 −0
  • internal/geoip/pointer_test.go +71 −0

CI cancelled — superseded by a newer push — run details

diff --git a/internal/geoip/answer.go b/internal/geoip/answer.go
new file mode 100644
index 000000000..cafe12c96
--- /dev/null
+++ b/internal/geoip/answer.go
@@ -0,0 +1,173 @@
+package geoip
+
+import (
+ "math"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/geo"
+)
+
+const (
+ HeaderCountry = "X-Carlos-Geo-Country"
+ HeaderSubdivision = "X-Carlos-Geo-Subdivision"
+ HeaderLat = "X-Carlos-Geo-Lat"
+ HeaderLon = "X-Carlos-Geo-Lon"
+ HeaderSource = "X-Carlos-Geo-Source"
+ // HeaderHop is edge-to-edge only (spec §8.1a): never written to a
+ // request an app receives.
+ HeaderHop = "X-Carlos-Hop-Geo"
+)
+
+var appHeaders = []string{HeaderCountry, HeaderSubdivision, HeaderLat, HeaderLon, HeaderSource}
+
+// State is what the ingress edge did: hit (a reader answered), miss (a
+// reader was loaded and found nothing, or the address is not locatable),
+// off (no lookup was attempted).
+type State string
+
+const (
+ StateHit State = "hit"
+ StateMiss State = "miss"
+ StateOff State = "off"
+)
+
+// Answer is one request's geo result, computed once at ingress or adopted
+// from a trusted transport. Lat/Lon are already rounded to 2 places.
+type Answer struct {
+ State State
+ Country string
+ Subdivision string
+ Source string
+ Lat, Lon float64
+ HasPoint bool
+}
+
+// Off is the answer for a request no lookup was attempted for.
+func Off() Answer { return Answer{State: StateOff} }
+
+func round2(v float64) float64 { return math.Round(v*100) / 100 }
+
+// FromSnapshot turns one reader's answer into a request's answer.
+func FromSnapshot(s geo.Snapshot) Answer {
+ switch {
+ case !s.Loaded:
+ return Off()
+ case !s.Found:
+ return Answer{State: StateMiss, Source: s.Source}
+ }
+ a := Answer{State: StateHit, Country: s.Result.Country, Subdivision: s.Result.Subdivision, Source: s.Source}
+ if s.Result.HasPoint {
+ a.Lat, a.Lon, a.HasPoint = round2(s.Result.Point.Lat), round2(s.Result.Point.Lon), true
+ }
+ return a
+}
+
+// StripAll deletes every geo header an app or an edge could receive.
+func StripAll(h http.Header) {
+ for _, k := range appHeaders {
+ h.Del(k)
+ }
+ h.Del(HeaderHop)
+}
+
+// WriteApp deletes the app headers and writes this answer's. A miss and
+// off write nothing at all; Source is written only beside another field.
+func (a Answer) WriteApp(h http.Header) {
+ for _, k := range appHeaders {
+ h.Del(k)
+ }
+ if a.State != StateHit {
+ return
+ }
+ wrote := false
+ if a.Country != "" {
+ h.Set(HeaderCountry, a.Country)
+ wrote = true
+ if a.Subdivision != "" {
+ h.Set(HeaderSubdivision, a.Subdivision)
+ }
+ }
+ if a.HasPoint {
+ h.Set(HeaderLat, strconv.FormatFloat(a.Lat, 'f', 2, 64))
+ h.Set(HeaderLon, strconv.FormatFloat(a.Lon, 'f', 2, 64))
+ wrote = true
+ }
+ if wrote && a.Source != "" {
+ h.Set(HeaderSource, a.Source)
+ }
+}
+
+// HopValue is X-Carlos-Hop-Geo for this answer.
+func (a Answer) HopValue() string {
+ if a.State == StateOff || !sourceRE.MatchString(a.Source) {
+ return string(StateOff)
+ }
+ return string(a.State) + ";src=" + a.Source
+}
+
+// Captured is what a request arrived carrying, before anyone trusts it.
+type Captured struct {
+ Hop, Country, Subdivision, Lat, Lon, Source string
+}
+
+// Capture copies the geo headers out of h and strips them all (spec §8.1
+// step 1). Nothing reads the copy until the transport is authenticated.
+func Capture(h http.Header) Captured {
+ c := Captured{
+ Hop: h.Get(HeaderHop), Country: h.Get(HeaderCountry), Subdivision: h.Get(HeaderSubdivision),
+ Lat: h.Get(HeaderLat), Lon: h.Get(HeaderLon), Source: h.Get(HeaderSource),
+ }
+ StripAll(h)
+ return c
+}
+
+var (
+ countryRE = regexp.MustCompile(`^[A-Z]{2}$`)
+ subdivisionRE = regexp.MustCompile(`^[A-Z]{2}-[A-Z0-9]{1,3}$`)
+ sourceRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
+)
+
+// Adopt validates what a TRUSTED transport carried (spec §8.4). Call it only
+// for a verified hop or a TLS tunnel. Invalid fields are dropped one by one;
+// a hit left with neither a country nor a coordinate pair, or a missing or
+// malformed hop value, is off.
+func (c Captured) Adopt() Answer {
+ state, src, ok := strings.Cut(c.Hop, ";src=")
+ switch {
+ case c.Hop == string(StateOff):
+ return Off()
+ case !ok || !sourceRE.MatchString(src):
+ return Off()
+ case State(state) == StateMiss:
+ return Answer{State: StateMiss, Source: src}
+ case State(state) != StateHit:
+ return Off()
+ }
+ a := Answer{State: StateHit, Source: src}
+ if countryRE.MatchString(c.Country) {
+ a.Country = c.Country
+ if subdivisionRE.MatchString(c.Subdivision) && strings.HasPrefix(c.Subdivision, c.Country+"-") {
+ a.Subdivision = c.Subdivision
+ }
+ }
+ if lat, lon, ok := parsePair(c.Lat, c.Lon); ok {
+ a.Lat, a.Lon, a.HasPoint = lat, lon, true
+ }
+ if a.Country == "" && !a.HasPoint {
+ return Off()
+ }
+ return a
+}
+
+func parsePair(latS, lonS string) (float64, float64, bool) {
+ lat, err1 := strconv.ParseFloat(latS, 64)
+ lon, err2 := strconv.ParseFloat(lonS, 64)
+ if err1 != nil || err2 != nil || math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) ||
+ lat < -90 || lat > 90 || lon < -180 || lon > 180 {
+ return 0, 0, false
+ }
+ return round2(lat), round2(lon), true
+}
diff --git a/internal/geoip/answer_test.go b/internal/geoip/answer_test.go
new file mode 100644
index 000000000..ce7855d65
--- /dev/null
+++ b/internal/geoip/answer_test.go
@@ -0,0 +1,81 @@
+package geoip
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/geo"
+)
+
+func TestFromSnapshotStates(t *testing.T) {
+ if a := FromSnapshot(geo.Snapshot{}); a.State != StateOff {
+ t.Fatalf("not loaded = %+v, want off", a)
+ }
+ if a := FromSnapshot(geo.Snapshot{Loaded: true, Source: "DBIP-City-Lite"}); a.State != StateMiss || a.Source != "DBIP-City-Lite" {
+ t.Fatalf("loaded miss = %+v", a)
+ }
+ hit := FromSnapshot(geo.Snapshot{Loaded: true, Found: true, Source: "GeoLite2-City",
+ Result: geo.Result{Country: "GB", Subdivision: "GB-ENG", Point: geo.Point{Lat: 51.5074, Lon: -0.1278}, HasPoint: true}})
+ if hit.State != StateHit || hit.Lat != 51.51 || hit.Lon != -0.13 {
+ t.Fatalf("hit = %+v (coordinates must be rounded to 2 places)", hit)
+ }
+}
+
+func TestWriteAppHeaders(t *testing.T) {
+ h := http.Header{}
+ h.Set(HeaderCountry, "XX") // stale value must go
+ Answer{State: StateHit, Country: "IE", Source: "DBIP-City-Lite", Lat: 53.34, Lon: -6.27, HasPoint: true}.WriteApp(h)
+ if h.Get(HeaderCountry) != "IE" || h.Get(HeaderLat) != "53.34" || h.Get(HeaderLon) != "-6.27" ||
+ h.Get(HeaderSource) != "DBIP-City-Lite" || h.Get(HeaderSubdivision) != "" || h.Get(HeaderHop) != "" {
+ t.Fatalf("headers = %v", h)
+ }
+ h2 := http.Header{}
+ h2.Set(HeaderSource, "forged")
+ Answer{State: StateMiss, Source: "DBIP-City-Lite"}.WriteApp(h2)
+ if len(h2) != 0 {
+ t.Fatalf("a miss sends no app headers, not even Source: %v", h2)
+ }
+}
+
+func TestHopValueRoundTrip(t *testing.T) {
+ for _, a := range []Answer{
+ {State: StateHit, Country: "GB", Subdivision: "GB-ENG", Source: "GeoLite2-City", Lat: 51.51, Lon: -0.09, HasPoint: true},
+ {State: StateHit, Source: "GeoLite2-City", Lat: 10, Lon: 20, HasPoint: true}, // coordinate-only (codex R3-3)
+ {State: StateMiss, Source: "DBIP-City-Lite"},
+ Off(),
+ } {
+ h := http.Header{}
+ a.WriteApp(h)
+ h.Set(HeaderHop, a.HopValue())
+ got := Capture(h).Adopt()
+ if got != a {
+ t.Errorf("round trip %+v -> %+v", a, got)
+ }
+ if len(h) != 0 {
+ t.Errorf("Capture must strip every geo header, left %v", h)
+ }
+ }
+}
+
+func TestAdoptRefusesWhatItCannotTrust(t *testing.T) {
+ cases := map[string]http.Header{
+ "no hop value": {HeaderCountry: {"GB"}, HeaderSource: {"x"}},
+ "malformed hop value": {HeaderHop: {"maybe"}},
+ "hit with nothing": {HeaderHop: {"hit;src=x"}},
+ "hit with bad country": {HeaderHop: {"hit;src=x"}, HeaderCountry: {"gb"}},
+ "bad source": {HeaderHop: {"hit;src=a b"}, HeaderCountry: {"GB"}},
+ }
+ for name, h := range cases {
+ if got := Capture(h).Adopt(); got.State != StateOff {
+ t.Errorf("%s: adopted %+v, want off", name, got)
+ }
+ }
+ // Field-level: an invalid subdivision or half a coordinate pair is
+ // dropped on its own, the rest survives.
+ h := http.Header{HeaderHop: {"hit;src=GeoLite2-City"}, HeaderCountry: {"GB"},
+ HeaderSubdivision: {"FR-IDF"}, HeaderLat: {"91"}, HeaderLon: {"0"}}
+ got := Capture(h).Adopt()
+ if got.State != StateHit || got.Country != "GB" || got.Subdivision != "" || got.HasPoint {
+ t.Fatalf("partial = %+v", got)
+ }
+}
diff --git a/internal/geoip/pointer.go b/internal/geoip/pointer.go
new file mode 100644
index 000000000..3cb5cb032
--- /dev/null
+++ b/internal/geoip/pointer.go
@@ -0,0 +1,110 @@
+// Package geoip is the platform's GeoIP vocabulary (spec
+// docs/superpowers/specs/2026-10-06-platform-geoip-design.md): the bucket
+// records the console writes and every edge reads, the GeoLite2 expiry rule
+// (D1), attribution, and the request headers. No I/O lives here.
+package geoip
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ Prefix = "control/geoip/"
+ PointerKey = Prefix + "current.json"
+ FeedKey = Prefix + "feed.json"
+ MaxBlobBytes = int64(1 << 30)
+ MaxPointerBytes = 64 << 10
+
+ geoLiteTTL = 30 * 24 * time.Hour
+ GeoLiteWarn = 21 * 24 * time.Hour
+ StaleAfter = 45 * 24 * time.Hour
+)
+
+// BlobKey is the content-addressed object for a database whose bytes hash
+// to sha (lower-case hex).
+func BlobKey(sha string) string { return Prefix + sha + ".mmdb" }
+
+// Pointer is control/geoip/current.json (spec §3).
+type Pointer struct {
+ V int `json:"v"`
+ SHA256 string `json:"sha256"`
+ Key string `json:"key"`
+ Size int64 `json:"size"`
+ DatabaseType string `json:"database_type"`
+ BuildEpoch int64 `json:"build_epoch"`
+ NodeCount uint64 `json:"node_count"`
+ Published time.Time `json:"published"`
+ Origin string `json:"origin"`
+ Edition string `json:"edition"`
+ Expired bool `json:"expired,omitempty"`
+}
+
+// Build is BuildEpoch as a time.
+func (p Pointer) Build() time.Time { return time.Unix(p.BuildEpoch, 0).UTC() }
+
+// ParsePointer decodes and checks a pointer. An unknown version, a key that
+// is not this sha's blob key, or an impossible size is an error: an edge
+// never follows a pointer it cannot fully vouch for.
+func ParsePointer(raw []byte) (Pointer, error) {
+ var p Pointer
+ if err := json.Unmarshal(raw, &p); err != nil {
+ return Pointer{}, fmt.Errorf("geoip pointer: %w", err)
+ }
+ if p.V != 1 {
+ return Pointer{}, fmt.Errorf("geoip pointer: unknown version %d", p.V)
+ }
+ if !IsSHA256Hex(p.SHA256) {
+ return Pointer{}, errors.New("geoip pointer: sha256 is not 64 lower-case hex characters")
+ }
+ if p.Key != BlobKey(p.SHA256) {
+ return Pointer{}, errors.New("geoip pointer: key is not the sha's blob key")
+ }
+ if p.Size <= 0 || p.Size > MaxBlobBytes {
+ return Pointer{}, fmt.Errorf("geoip pointer: size %d out of range", p.Size)
+ }
+ return p, nil
+}
+
+// IsSHA256Hex is 64 lower-case hex characters.
+func IsSHA256Hex(s string) bool {
+ if len(s) != 64 || strings.ToLower(s) != s {
+ return false
+ }
+ _, err := hex.DecodeString(s)
+ return err == nil
+}
+
+// ExpiresAt is D1: a GeoLite2 database expires 30 days after it was built.
+// ok is false for every other type, which never expires.
+func ExpiresAt(dbType string, build time.Time) (time.Time, bool) {
+ if !strings.HasPrefix(dbType, "GeoLite2-") {
+ return time.Time{}, false
+ }
+ return build.Add(geoLiteTTL), true
+}
+
+// Expired reports whether now is at or past ExpiresAt.
+func Expired(dbType string, build, now time.Time) bool {
+ at, ok := ExpiresAt(dbType, build)
+ return ok && !now.Before(at)
+}
+
+// Attribution is the credit a page showing the data must carry (spec §13).
+type Attribution struct{ Text, URL string }
+
+// AttributionFor names the attribution for a database type. ok is false
+// when none is known: the operator's own licence governs.
+func AttributionFor(dbType string) (Attribution, bool) {
+ switch {
+ case strings.HasPrefix(dbType, "DBIP-"):
+ return Attribution{"IP Geolocation by DB-IP", "https://db-ip.com"}, true
+ case strings.HasPrefix(dbType, "GeoLite2-"):
+ return Attribution{"This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com", "https://www.maxmind.com"}, true
+ }
+ return Attribution{}, false
+}
diff --git a/internal/geoip/pointer_test.go b/internal/geoip/pointer_test.go
new file mode 100644
index 000000000..6d515fb57
--- /dev/null
+++ b/internal/geoip/pointer_test.go
@@ -0,0 +1,71 @@
+package geoip
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+const sha = "9f2c000000000000000000000000000000000000000000000000000000000001"
+
+func TestParsePointerAcceptsTheSpecShape(t *testing.T) {
+ raw := `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":10,
+ "database_type":"DBIP-City-Lite","build_epoch":1790812800,"node_count":7,
+ "published":"2026-10-06T21:00:00Z","origin":"manual","edition":""}`
+ p, err := ParsePointer([]byte(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Key != BlobKey(sha) || p.Size != 10 || p.DatabaseType != "DBIP-City-Lite" || p.Build().Unix() != 1790812800 {
+ t.Fatalf("pointer = %+v", p)
+ }
+}
+
+func TestParsePointerRefusesWhatAnEdgeMustNotFollow(t *testing.T) {
+ good := `"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":10,"database_type":"X","build_epoch":1`
+ bad := map[string]string{
+ "unknown version": `{"v":2,` + good + `}`,
+ "short sha": `{"v":1,"sha256":"9f","key":"control/geoip/9f.mmdb","size":1,"database_type":"X","build_epoch":1}`,
+ "key not the blob key": `{"v":1,"sha256":"` + sha + `","key":"apps/x/secret","size":10,"database_type":"X","build_epoch":1}`,
+ "zero size": `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":0,"database_type":"X","build_epoch":1}`,
+ "oversize": `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":1073741825,"database_type":"X","build_epoch":1}`,
+ "not json": `nope`,
+ }
+ for name, raw := range bad {
+ if _, err := ParsePointer([]byte(raw)); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+}
+
+func TestGeoLiteExpiresThirtyDaysAfterBuild(t *testing.T) {
+ build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ at, ok := ExpiresAt("GeoLite2-City", build)
+ if !ok || !at.Equal(build.Add(30*24*time.Hour)) {
+ t.Fatalf("ExpiresAt = (%v, %v)", at, ok)
+ }
+ if Expired("GeoLite2-City", build, build.Add(30*24*time.Hour-time.Second)) {
+ t.Fatal("expired a second early")
+ }
+ if !Expired("GeoLite2-Country", build, build.Add(30*24*time.Hour)) {
+ t.Fatal("not expired at 30 days")
+ }
+ if Expired("DBIP-City-Lite", build, build.Add(400*24*time.Hour)) {
+ t.Fatal("DB-IP must never expire (D1)")
+ }
+ if _, ok := ExpiresAt("GeoIP2-City", build); ok {
+ t.Fatal("only GeoLite2- expires")
+ }
+}
+
+func TestAttributionTable(t *testing.T) {
+ if a, ok := AttributionFor("DBIP-City-Lite"); !ok || a.Text != "IP Geolocation by DB-IP" || a.URL != "https://db-ip.com" {
+ t.Fatalf("dbip = %+v %v", a, ok)
+ }
+ if a, ok := AttributionFor("GeoLite2-Country"); !ok || !strings.Contains(a.Text, "GeoLite2 data created by MaxMind") {
+ t.Fatalf("geolite = %+v %v", a, ok)
+ }
+ if _, ok := AttributionFor("GeoIP2-City"); ok {
+ t.Fatal("no attribution is known for commercial GeoIP2; the operator's licence governs")
+ }
+}