diff --git a/Dockerfile b/Dockerfile
index 38cf0762..1279d179 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -20,6 +20,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
+COPY internal/dbschema/ ../../internal/dbschema/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
@@ -33,6 +34,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
+COPY internal/dbschema/ ../../internal/dbschema/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
diff --git a/cmd/server/config.go b/cmd/server/config.go
index 047ac041..93ecc7c5 100644
--- a/cmd/server/config.go
+++ b/cmd/server/config.go
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
+ "fmt"
"log"
"os"
"path/filepath"
@@ -447,6 +448,68 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
}
+// SaveGeoFilter writes the geo_filter section back to config.json on disk.
+// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
+func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
+ var configPath string
+ for _, p := range []string{
+ filepath.Join(configDir, "config.json"),
+ filepath.Join(configDir, "data", "config.json"),
+ } {
+ if _, err := os.Stat(p); err == nil {
+ configPath = p
+ break
+ }
+ }
+ if configPath == "" {
+ return fmt.Errorf("config.json not found in %s", configDir)
+ }
+
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return fmt.Errorf("read config: %w", err)
+ }
+
+ // Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return fmt.Errorf("parse config: %w", err)
+ }
+
+ if gf == nil || len(gf.Polygon) == 0 {
+ delete(raw, "geo_filter")
+ } else {
+ // Round-trip through JSON to get a plain interface{} value.
+ b, _ := json.Marshal(gf)
+ var v interface{}
+ _ = json.Unmarshal(b, &v)
+ raw["geo_filter"] = v
+ }
+
+ out, err := json.MarshalIndent(raw, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ out = append(out, '\n')
+
+ // Preserve the original file mode so operators' chmod 0600 survives the write.
+ origMode := os.FileMode(0644)
+ if fi, err := os.Stat(configPath); err == nil {
+ origMode = fi.Mode().Perm()
+ }
+
+ // Atomic write: temp file + rename.
+ tmp := configPath + ".tmp"
+ if err := os.WriteFile(tmp, out, origMode); err != nil {
+ return fmt.Errorf("write config: %w", err)
+ }
+ if err := os.Rename(tmp, configPath); err != nil {
+ os.Remove(tmp)
+ return fmt.Errorf("rename config: %w", err)
+ }
+ return nil
+}
+
// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
func (c *Config) obsBlacklistSet() map[string]bool {
c.obsBlacklistOnce.Do(func() {
@@ -485,8 +548,8 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
// RecomputeIntervalSeconds keys (all optional):
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
type AnalyticsConfig struct {
- DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
- RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
+ DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
+ RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
}
// AnalyticsDefaultRecomputeInterval returns the configured default
diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go
index 36e59ea9..8323dbd2 100644
--- a/cmd/server/config_test.go
+++ b/cmd/server/config_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "runtime"
"testing"
)
@@ -387,3 +388,25 @@ func TestObserverDaysOrDefault(t *testing.T) {
})
}
}
+
+func TestSaveGeoFilter_PreservesFileMode(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Unix file permissions not supported on Windows")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(path, []byte(`{"port":3000}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ gf := &GeoFilterConfig{Polygon: [][2]float64{{1, 2}, {3, 4}, {5, 6}}, BufferKm: 0}
+ if err := SaveGeoFilter(dir, gf); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0600 {
+ t.Errorf("file mode downgraded: want 0600, got %04o", got)
+ }
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 7bb27b06..1a1dbce0 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -273,6 +273,7 @@ func main() {
// HTTP server
srv := NewServer(database, cfg, hub)
+ srv.configDir = configDir
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index 62d1d0ea..b72b06e2 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log"
+ "math"
"net/http"
"regexp"
"runtime"
@@ -25,12 +26,20 @@ type Server struct {
cfg *Config
hub *Hub
store *PacketStore // in-memory packet store (nil = fallback to DB)
+ configDir string // directory containing config.json (for write-back)
startedAt time.Time
perfStats *PerfStats
version string
commit string
buildTime string
+ // Guards s.cfg.GeoFilter — read by ingest/handler goroutines, written by PUT handler
+ cfgMu sync.RWMutex
+
+ // Serializes concurrent PUT /api/config/geo-filter disk writes so requests
+ // can't race on the .tmp file or interleave disk/memory updates.
+ saveMu sync.Mutex
+
// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
memStatsMu sync.Mutex
memStatsCache runtime.MemStats
@@ -59,6 +68,18 @@ type PerfStats struct {
StartedAt time.Time
}
+func (s *Server) getGeoFilter() *GeoFilterConfig {
+ s.cfgMu.RLock()
+ defer s.cfgMu.RUnlock()
+ return s.cfg.GeoFilter
+}
+
+func (s *Server) setGeoFilter(gf *GeoFilterConfig) {
+ s.cfgMu.Lock()
+ defer s.cfgMu.Unlock()
+ s.cfg.GeoFilter = gf
+}
+
type EndpointPerf struct {
Count int
TotalMs float64
@@ -120,6 +141,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
+ r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
// Readiness endpoint (gated on background init completion)
r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET")
@@ -444,7 +466,11 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
- gf := s.cfg.GeoFilter
+ gf := s.getGeoFilter()
+ // NOTE: do NOT include any field that derives from APIKey presence/strength here.
+ // This endpoint is intentionally public; leaking whether a write-capable key is
+ // configured is an info-disclosure. Clients that want to write should just try
+ // PUT and handle 401/403. See PR #736 review.
if gf == nil || len(gf.Polygon) == 0 {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
return
@@ -452,6 +478,66 @@ func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
}
+func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap
+
+ var body struct {
+ Polygon [][2]float64 `json:"polygon"`
+ BufferKm float64 `json:"bufferKm"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+
+ // Allow clearing (empty/null polygon) or a valid polygon with ≥ 3 points.
+ if len(body.Polygon) > 0 && len(body.Polygon) < 3 {
+ writeError(w, http.StatusBadRequest, "polygon must have at least 3 points")
+ return
+ }
+ if len(body.Polygon) > 1000 {
+ writeError(w, http.StatusBadRequest, "polygon must have at most 1000 points")
+ return
+ }
+ for _, pt := range body.Polygon {
+ if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) || math.IsInf(pt[0], 0) || math.IsInf(pt[1], 0) ||
+ pt[0] < -90 || pt[0] > 90 || pt[1] < -180 || pt[1] > 180 {
+ writeError(w, http.StatusBadRequest, "polygon point out of range: lat must be in [-90,90], lon in [-180,180]")
+ return
+ }
+ }
+
+ // bufferKm must be finite, non-negative, and ≤ 20000 km (half Earth circumference).
+ if math.IsNaN(body.BufferKm) || math.IsInf(body.BufferKm, 0) ||
+ body.BufferKm < 0 || body.BufferKm > 20000 {
+ writeError(w, http.StatusBadRequest, "bufferKm must be a finite number in [0, 20000]")
+ return
+ }
+
+ var gf *GeoFilterConfig
+ if len(body.Polygon) >= 3 {
+ gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm}
+ }
+
+ s.saveMu.Lock()
+ if s.configDir != "" {
+ if err := SaveGeoFilter(s.configDir, gf); err != nil {
+ s.saveMu.Unlock()
+ log.Printf("[geofilter] save failed: %v", err)
+ writeError(w, http.StatusInternalServerError, "failed to save config")
+ return
+ }
+ }
+ s.setGeoFilter(gf)
+ s.saveMu.Unlock()
+
+ if gf != nil {
+ writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
+ } else {
+ writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
+ }
+}
+
// --- System Handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -1145,20 +1231,17 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
}
}
- if s.cfg.GeoFilter != nil {
+ if gf := s.getGeoFilter(); gf != nil {
filtered := nodes[:0]
for _, node := range nodes {
// Foreign-flagged nodes (#730) are kept even when their GPS lies
- // outside the geofilter polygon — that's the whole point of the
- // flag: operators need to SEE bridged/leaked nodes, not have them
- // filtered away. The ingestor sets foreign_advert=1 when its
- // configured geo_filter rejected the advert; the server must
- // surface those.
+ // outside the geofilter polygon — operators need to SEE bridged/
+ // leaked nodes, not have them filtered away.
if isForeign, _ := node["foreign"].(bool); isForeign {
filtered = append(filtered, node)
continue
}
- if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
+ if NodePassesGeoFilter(node["lat"], node["lon"], gf) {
filtered = append(filtered, node)
}
}
diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go
index b0bd6195..2ddc5b2e 100644
--- a/cmd/server/routes_test.go
+++ b/cmd/server/routes_test.go
@@ -3,10 +3,14 @@ package main
import (
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strconv"
"strings"
+ "sync"
"testing"
"time"
@@ -2839,6 +2843,32 @@ func TestConfigGeoFilterEndpoint(t *testing.T) {
if body["bufferKm"] == nil {
t.Error("expected bufferKm in response")
}
+ // writeEnabled must NOT be leaked: the public GET endpoint should not
+ // disclose whether a strong apiKey is configured to unauthenticated callers.
+ if _, ok := body["writeEnabled"]; ok {
+ t.Errorf("writeEnabled must not be present in public GET response (info disclosure), got %v", body["writeEnabled"])
+ }
+ })
+
+ t.Run("writeEnabled is not exposed even when strong apiKey configured", func(t *testing.T) {
+ db := setupTestDB(t)
+ cfg := &Config{Port: 3000, APIKey: "a-strong-api-key-1234"}
+ hub := NewHub()
+ srv := NewServer(db, cfg, hub)
+ srv.store = NewPacketStore(db, nil)
+ srv.store.Load()
+ router := mux.NewRouter()
+ srv.RegisterRoutes(router)
+
+ req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ var body map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &body)
+ if _, ok := body["writeEnabled"]; ok {
+ t.Errorf("writeEnabled must not be present (would leak apiKey presence), got %v", body["writeEnabled"])
+ }
})
}
@@ -3973,3 +4003,262 @@ func TestPacketDetailPrefersStoreOverDB(t *testing.T) {
t.Errorf("expected observation_count=2 (from store), got %v", body["observation_count"])
}
}
+
+// --- geo-filter write-back tests ---
+
+func setupGeoFilterServer(t *testing.T, apiKey string) (*Server, *mux.Router, string) {
+ t.Helper()
+ dir := t.TempDir()
+ cfgJSON := `{"port":3000,"apiKey":"` + apiKey + `"}`
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfgJSON), 0644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ db := setupTestDB(t)
+ seedTestData(t, db)
+ cfg := &Config{Port: 3000, APIKey: apiKey}
+ hub := NewHub()
+ srv := NewServer(db, cfg, hub)
+ srv.configDir = dir
+ store := NewPacketStore(db, nil)
+ if err := store.Load(); err != nil {
+ t.Fatalf("store.Load: %v", err)
+ }
+ srv.store = store
+ router := mux.NewRouter()
+ srv.RegisterRoutes(router)
+ return srv, router, dir
+}
+
+func TestPutConfigGeoFilter(t *testing.T) {
+ const apiKey = "a-strong-api-key-for-testing"
+
+ t.Run("saves valid polygon and updates in-memory config", func(t *testing.T) {
+ srv, router, dir := setupGeoFilterServer(t, apiKey)
+
+ body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0],[50.5,4.0]],"bufferKm":15}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // In-memory config updated
+ if srv.cfg.GeoFilter == nil {
+ t.Fatal("expected in-memory GeoFilter to be set")
+ }
+ if len(srv.cfg.GeoFilter.Polygon) != 4 {
+ t.Errorf("expected 4 polygon points, got %d", len(srv.cfg.GeoFilter.Polygon))
+ }
+ if srv.cfg.GeoFilter.BufferKm != 15 {
+ t.Errorf("expected bufferKm=15, got %v", srv.cfg.GeoFilter.BufferKm)
+ }
+
+ // config.json updated on disk
+ data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
+ if !bytes.Contains(data, []byte("geo_filter")) {
+ t.Error("expected geo_filter key in saved config.json")
+ }
+ })
+
+ t.Run("clears filter when polygon is empty", func(t *testing.T) {
+ srv, router, dir := setupGeoFilterServer(t, apiKey)
+ // Pre-set a filter so we can clear it
+ srv.setGeoFilter(&GeoFilterConfig{Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, BufferKm: 10})
+
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(`{"polygon":null}`))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ if srv.cfg.GeoFilter != nil {
+ t.Error("expected in-memory GeoFilter to be cleared")
+ }
+ data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
+ if bytes.Contains(data, []byte("geo_filter")) {
+ t.Error("expected geo_filter to be removed from config.json")
+ }
+ })
+
+ t.Run("rejects polygon with fewer than 3 points", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+
+ body := `{"polygon":[[51.0,4.0],[51.0,5.0]],"bufferKm":0}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+ })
+
+ t.Run("rejects out-of-range coordinates", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+
+ body := `{"polygon":[[91.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for out-of-range lat, got %d", w.Code)
+ }
+ })
+
+ t.Run("rejects polygon exceeding 1000 points", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+
+ pts := make([][2]float64, 1001)
+ for i := range pts {
+ pts[i] = [2]float64{51.0 + float64(i)*0.0001, 4.0}
+ }
+ b, _ := json.Marshal(map[string]interface{}{"polygon": pts, "bufferKm": 0})
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(string(b)))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for oversized polygon, got %d", w.Code)
+ }
+ })
+
+ t.Run("rejects missing API key", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+
+ body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+ })
+
+ t.Run("rejects negative bufferKm", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+ body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":-1}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for negative bufferKm, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("rejects excessive bufferKm", func(t *testing.T) {
+ _, router, _ := setupGeoFilterServer(t, apiKey)
+ body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":99999999}`
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for excessive bufferKm, got %d: %s", w.Code, w.Body.String())
+ }
+ })
+}
+
+func TestPutConfigGeoFilter_ConcurrentSafe(t *testing.T) {
+ const apiKey = "a-strong-api-key-for-testing"
+ srv, router, dir := setupGeoFilterServer(t, apiKey)
+ _ = srv
+
+ const n = 10
+ var wg sync.WaitGroup
+ errs := make(chan string, n)
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ lat := 51.0 + float64(i)*0.001
+ body := fmt.Sprintf(`{"polygon":[[%f,4.0],[%f,5.0],[50.5,4.0]],"bufferKm":0}`, lat, lat)
+ req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ errs <- fmt.Sprintf("goroutine %d: got %d: %s", i, w.Code, w.Body.String())
+ }
+ }(i)
+ }
+ wg.Wait()
+ close(errs)
+ for e := range errs {
+ t.Error(e)
+ }
+ // config.json must be valid JSON after concurrent writes
+ data, err := os.ReadFile(filepath.Join(dir, "config.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var v interface{}
+ if err := json.Unmarshal(data, &v); err != nil {
+ t.Errorf("config.json corrupted after concurrent PUTs: %v\ncontents: %s", err, data)
+ }
+}
+
+func TestSaveGeoFilter(t *testing.T) {
+ t.Run("saves and reads back", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port":3000}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ gf := &GeoFilterConfig{
+ Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}},
+ BufferKm: 20,
+ }
+ if err := SaveGeoFilter(dir, gf); err != nil {
+ t.Fatalf("SaveGeoFilter: %v", err)
+ }
+ data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
+ if !bytes.Contains(data, []byte("geo_filter")) {
+ t.Error("expected geo_filter in saved config")
+ }
+ if !bytes.Contains(data, []byte(`"bufferKm"`)) {
+ t.Error("expected bufferKm in saved config")
+ }
+ })
+
+ t.Run("removes geo_filter key when gf is nil", func(t *testing.T) {
+ dir := t.TempDir()
+ initial := `{"port":3000,"geo_filter":{"polygon":[[1,2],[3,4],[5,6]],"bufferKm":5}}`
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(initial), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := SaveGeoFilter(dir, nil); err != nil {
+ t.Fatalf("SaveGeoFilter: %v", err)
+ }
+ data, _ := os.ReadFile(filepath.Join(dir, "config.json"))
+ if bytes.Contains(data, []byte("geo_filter")) {
+ t.Error("expected geo_filter to be removed")
+ }
+ })
+
+ t.Run("returns error when config.json not found", func(t *testing.T) {
+ dir := t.TempDir()
+ err := SaveGeoFilter(dir, nil)
+ if err == nil {
+ t.Error("expected error when config.json not found")
+ }
+ })
+}
diff --git a/config.example.json b/config.example.json
index 64b23f03..ae15cab0 100644
--- a/config.example.json
+++ b/config.example.json
@@ -175,7 +175,7 @@
[37.20, -122.52]
],
"bufferKm": 20,
- "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
+ "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
},
"foreignAdverts": {
"mode": "flag",
diff --git a/docs/superpowers/plans/2026-04-05-deep-linking-p1.md b/docs/superpowers/plans/2026-04-05-deep-linking-p1.md
deleted file mode 100644
index 23a23ba4..00000000
--- a/docs/superpowers/plans/2026-04-05-deep-linking-p1.md
+++ /dev/null
@@ -1,674 +0,0 @@
-# Deep Linking P1 Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared.
-
-**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present.
-
-**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams
-
----
-
-## Files Changed
-
-| File | Changes |
-|---|---|
-| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render |
-| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` |
-| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` |
-| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` |
-| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` |
-| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence |
-
----
-
-## Task 1: Add `setSelected` to RegionFilter
-
-**Files:**
-- Modify: `public/region-filter.js`
-
-- [ ] **Step 1: Write the failing unit test**
-
-Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line:
-
-```javascript
-// ===== REGION-FILTER.JS: setSelected =====
-console.log('\n=== region-filter.js: setSelected ===');
-{
- const ctx = makeSandbox();
- ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
- loadInCtx(ctx, 'public/region-filter.js');
-
- const RF = ctx.RegionFilter;
- RF.init(document.createElement('div'));
-
- test('setSelected sets region codes', async () => {
- await RF.init(document.createElement('div'));
- RF.setSelected(['US-SFO', 'US-LAX']);
- assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
- });
-
- test('setSelected with null clears selection', async () => {
- await RF.init(document.createElement('div'));
- RF.setSelected(['US-SFO']);
- RF.setSelected(null);
- assert.strictEqual(RF.getRegionParam(), '');
- });
-
- test('setSelected with empty array clears selection', async () => {
- await RF.init(document.createElement('div'));
- RF.setSelected(['US-SFO']);
- RF.setSelected([]);
- assert.strictEqual(RF.getRegionParam(), '');
- });
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected"
-```
-
-Expected: `❌ setSelected sets region codes: RF.setSelected is not a function`
-
-- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js**
-
-In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`):
-
-```javascript
- var _listeners = [];
- var _container = null; // ← add this line
- var _loaded = false;
-```
-
-In `initFilter`, save the container:
-
-```javascript
- async function initFilter(container, opts) {
- _container = container; // ← add this line
- if (opts && opts.dropdown) container._forceDropdown = true;
- await fetchRegions();
- render(container);
- }
-```
-
-Add `setSelected` function before `// Expose globally`:
-
-```javascript
- /** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
- function setSelected(codesArray) {
- _selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
- saveToStorage();
- if (_container) render(_container);
- }
-```
-
-Add `setSelected` to the public API object:
-
-```javascript
- window.RegionFilter = {
- init: initFilter,
- render: render,
- getSelected: getSelected,
- getRegionParam: getRegionParam,
- regionQueryString: regionQueryString,
- onChange: onChange,
- offChange: offChange,
- fetchRegions: fetchRegions,
- setSelected: setSelected, // ← add this line
- };
-```
-
-- [ ] **Step 4: Run test to verify it passes**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)"
-```
-
-Expected: 3 passing `setSelected` tests, overall pass.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add public/region-filter.js test-frontend-helpers.js
-git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)"
-```
-
----
-
-## Task 2: nodes.js — tab and search deep linking
-
-**Files:**
-- Modify: `public/nodes.js`
-- Test: `test-frontend-helpers.js`
-- Test: `test-e2e-playwright.js`
-
-- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)**
-
-Add before the `// ===== SUMMARY =====` line:
-
-```javascript
-// ===== NODES.JS: buildNodesQuery =====
-console.log('\n=== nodes.js: buildNodesQuery ===');
-{
- const ctx = makeSandbox();
- loadInCtx(ctx, 'public/roles.js');
- loadInCtx(ctx, 'public/app.js');
-
- // Provide required globals for nodes.js IIFE to execute
- ctx.registerPage = () => {};
- ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
- ctx.onWS = () => {};
- ctx.offWS = () => {};
- ctx.debouncedOnWS = () => () => {};
- ctx.invalidateApiCache = () => {};
- ctx.favStar = () => '';
- ctx.bindFavStars = () => {};
- ctx.getFavorites = () => [];
- ctx.isFavorite = () => false;
- ctx.connectWS = () => {};
- ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
- ctx.initTabBar = () => {};
- ctx.debounce = (fn) => fn;
- ctx.copyToClipboard = () => {};
- ctx.api = () => Promise.resolve({});
- ctx.escapeHtml = (s) => s;
- ctx.timeAgo = () => '';
- ctx.formatTimestampWithTooltip = () => '';
- ctx.getTimestampMode = () => 'ago';
- ctx.CLIENT_TTL = {};
- ctx.qrcode = null;
-
- try {
- const src = fs.readFileSync('public/nodes.js', 'utf8');
- vm.runInContext(src, ctx);
- for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
- } catch (e) {
- console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
- }
-
- const buildNodesQuery = ctx.buildNodesQuery;
-
- if (buildNodesQuery) {
- test('buildNodesQuery: all tab + no search = empty', () => {
- assert.strictEqual(buildNodesQuery('all', ''), '');
- });
- test('buildNodesQuery: repeater tab only', () => {
- assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
- });
- test('buildNodesQuery: search only (all tab)', () => {
- assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
- });
- test('buildNodesQuery: tab + search combined', () => {
- assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
- });
- test('buildNodesQuery: null search treated as empty', () => {
- assert.strictEqual(buildNodesQuery('all', null), '');
- });
- test('buildNodesQuery: sensor tab', () => {
- assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
- });
- } else {
- console.log(' ⚠️ buildNodesQuery not exposed — skipping');
- }
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails (or skips)**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery"
-```
-
-Expected: `⚠️ buildNodesQuery not exposed — skipping`
-
-- [ ] **Step 3: Add URL param reading and helpers to nodes.js**
-
-**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`):
-
-```javascript
- function buildNodesQuery(tab, searchStr) {
- var parts = [];
- if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
- if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
- return parts.length ? '?' + parts.join('&') : '';
- }
- window.buildNodesQuery = buildNodesQuery;
-
- function updateNodesUrl() {
- history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
- }
-```
-
-**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`:
-
-```javascript
- // Read URL params for list view (router strips query string from routeParam)
- const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
- const _urlTab = _listUrlParams.get('tab');
- const _urlSearch = _listUrlParams.get('search');
- if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
- if (_urlSearch) search = _urlSearch;
-
- app.innerHTML = `
-```
-
-**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input:
-
-```javascript
- if (search) {
- var _si = document.getElementById('nodeSearch');
- if (_si) _si.value = search;
- }
-```
-
-**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`:
-
-```javascript
- document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
- search = e.target.value;
- updateNodesUrl();
- loadNodes();
- }, 250));
-```
-
-**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`:
-
-```javascript
- btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
-```
-
-- [ ] **Step 4: Run unit tests**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers"
-```
-
-Expected: 6 passing `buildNodesQuery` tests.
-
-- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)**
-
-Add before the closing `await browser.close()` line:
-
-```javascript
- // --- Group: Deep linking (#536) ---
-
- // Test: nodes tab deep link
- await test('Nodes tab deep link restores active tab', async () => {
- await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('.node-tab', { timeout: 8000 });
- const activeTab = await page.$('.node-tab.active');
- assert(activeTab, 'No active tab found');
- const tabText = await activeTab.textContent();
- assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
- const url = page.url();
- assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
- });
-
- // Test: nodes tab click updates URL
- await test('Nodes tab click updates URL', async () => {
- await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('.node-tab', { timeout: 8000 });
- const roomTab = await page.$('.node-tab[data-tab="room"]');
- if (roomTab) {
- await roomTab.click();
- await page.waitForTimeout(300);
- const url = page.url();
- assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
- }
- });
-```
-
-- [ ] **Step 6: Run full test suite**
-
-```bash
-node test-frontend-helpers.js
-```
-
-Expected: all tests pass.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js
-git commit -m "feat: deep link nodes tab and search query (#536)"
-```
-
----
-
-## Task 3: packets.js — timeWindow and region deep linking
-
-**Files:**
-- Modify: `public/packets.js`
-- Test: `test-frontend-helpers.js`
-- Test: `test-e2e-playwright.js`
-
-> Depends on Task 1 (RegionFilter.setSelected).
-
-- [ ] **Step 1: Write the unit test**
-
-Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`:
-
-```javascript
-// ===== PACKETS.JS: buildPacketsUrl =====
-console.log('\n=== packets.js: buildPacketsUrl ===');
-{
- // Test the pure helper function
- // (loaded via packets.js after it exposes window.buildPacketsUrl)
- const ctx = makeSandbox();
- loadInCtx(ctx, 'public/roles.js');
- loadInCtx(ctx, 'public/app.js');
-
- ctx.registerPage = () => {};
- ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
- ctx.onWS = () => {};
- ctx.offWS = () => {};
- ctx.debouncedOnWS = () => () => {};
- ctx.invalidateApiCache = () => {};
- ctx.api = () => Promise.resolve({});
- ctx.observerMap = new Map();
- ctx.getParsedPath = () => [];
- ctx.getParsedDecoded = () => ({});
- ctx.clearParsedCache = () => {};
- ctx.escapeHtml = (s) => s;
- ctx.timeAgo = () => '';
- ctx.formatTimestampWithTooltip = () => '';
- ctx.getTimestampMode = () => 'ago';
- ctx.copyToClipboard = () => {};
- ctx.CLIENT_TTL = {};
- ctx.debounce = (fn) => fn;
- ctx.initTabBar = () => {};
-
- try {
- const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
- vm.runInContext(src, ctx);
- for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
- const src2 = fs.readFileSync('public/packets.js', 'utf8');
- vm.runInContext(src2, ctx);
- for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
- } catch (e) {
- console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
- }
-
- const buildPacketsUrl = ctx.buildPacketsUrl;
-
- if (buildPacketsUrl) {
- test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => {
- assert.strictEqual(buildPacketsUrl(15, ''), '#/packets');
- });
- test('buildPacketsUrl: non-default timeWindow', () => {
- assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60');
- });
- test('buildPacketsUrl: region only', () => {
- assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO');
- });
- test('buildPacketsUrl: timeWindow + region', () => {
- assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30®ion=US-SFO%2CUS-LAX');
- });
- test('buildPacketsUrl: timeWindow=0 treated as default', () => {
- assert.strictEqual(buildPacketsUrl(0, ''), '#/packets');
- });
- } else {
- console.log(' ⚠️ buildPacketsUrl not exposed — skipping');
- }
-}
-```
-
-- [ ] **Step 2: Run to verify it skips**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl"
-```
-
-Expected: `⚠️ buildPacketsUrl not exposed — skipping`
-
-- [ ] **Step 3: Add helpers and URL param reading to packets.js**
-
-**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`):
-
-```javascript
- function buildPacketsUrl(timeWindowMin, regionParam) {
- var parts = [];
- if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin);
- if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
- return '#/packets' + (parts.length ? '?' + parts.join('&') : '');
- }
- window.buildPacketsUrl = buildPacketsUrl;
-
- function updatePacketsUrl() {
- history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam()));
- }
-```
-
-**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`:
-
-```javascript
- // Read URL params for filter state (router strips query from routeParam; read from location.hash)
- var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
- var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
- if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
- savedTimeWindowMin = _urlTimeWindow;
- localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
- }
- var _urlRegion = _initUrlParams.get('region');
- if (_urlRegion) {
- RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean));
- }
-
- app.innerHTML = `
-```
-
-**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`:
-
-```javascript
- fTimeWindow.addEventListener('change', () => {
- savedTimeWindowMin = Number(fTimeWindow.value);
- if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
- localStorage.setItem('meshcore-time-window', fTimeWindow.value);
- updatePacketsUrl();
- loadPackets();
- });
-```
-
-**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`:
-
-```javascript
- RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
-```
-
-- [ ] **Step 4: Run unit tests**
-
-```bash
-node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers"
-```
-
-Expected: 5 passing `buildPacketsUrl` tests.
-
-- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
-
-```javascript
- // Test: packets timeWindow deep link
- await test('Packets timeWindow deep link restores dropdown', async () => {
- await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
- const val = await page.$eval('#fTimeWindow', el => el.value);
- assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
- const url = page.url();
- assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
- });
-
- // Test: timeWindow change updates URL
- await test('Packets timeWindow change updates URL', async () => {
- await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
- await page.selectOption('#fTimeWindow', '30');
- await page.waitForTimeout(300);
- const url = page.url();
- assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
- });
-```
-
-- [ ] **Step 6: Run full test suite**
-
-```bash
-node test-frontend-helpers.js
-```
-
-Expected: all tests pass.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js
-git commit -m "feat: deep link packets timeWindow and region filter (#536)"
-```
-
----
-
-## Task 4: channels.js — node panel deep linking
-
-**Files:**
-- Modify: `public/channels.js`
-
-No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it.
-
-- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
-
-```javascript
- // Test: channels selected channel survives refresh (already implemented, verify it still works)
- await test('Channels channel selection is URL-addressable', async () => {
- await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
- const firstChannel = await page.$('.ch-item');
- if (firstChannel) {
- await firstChannel.click();
- await page.waitForTimeout(500);
- const url = page.url();
- assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
- }
- });
-```
-
-- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL**
-
-In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`:
-
-```javascript
- async function showNodeDetail(name) {
- _nodePanelTrigger = document.activeElement;
- if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
- const node = await lookupNode(name);
- selectedNode = name;
- var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
- history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
-
- let panel = document.getElementById('chNodePanel');
-```
-
-- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL**
-
-In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`:
-
-```javascript
- function closeNodeDetail() {
- if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
- const panel = document.getElementById('chNodePanel');
- if (panel) panel.classList.remove('open');
- selectedNode = null;
- var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
- history.replaceState(null, '', _chRestoreUrl);
- if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
-```
-
-- [ ] **Step 4: Read `?node=` on init and auto-open panel**
-
-In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`):
-
-```javascript
- function init(app, routeParam) {
- var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
- var _pendingNode = _initUrlParams.get('node');
-
- app.innerHTML = `
-```
-
-Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel:
-
-```javascript
- loadChannels().then(async function () {
- if (routeParam) await selectChannel(routeParam);
- if (_pendingNode) showNodeDetail(_pendingNode);
- });
-```
-
-- [ ] **Step 5: Run full test suite**
-
-```bash
-node test-frontend-helpers.js
-```
-
-Expected: all tests pass (no channels unit tests, but regression tests still pass).
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add public/channels.js
-git commit -m "feat: deep link channels node panel via ?node= (#536)"
-```
-
----
-
-## Task 5: Run E2E Playwright tests
-
-- [ ] **Step 1: Start the local server**
-
-```bash
-cd cmd/server && go run . &
-```
-
-Wait for it to be ready (check `http://localhost:3000`).
-
-- [ ] **Step 2: Run Playwright tests**
-
-```bash
-node test-e2e-playwright.js
-```
-
-Expected: all tests pass including the new deep-linking group.
-
-- [ ] **Step 3: If any deep-linking test fails, debug**
-
-Common failures:
-- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering
-- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built
-- URL doesn't update: check `history.replaceState` calls in the change handlers
-
-- [ ] **Step 4: Final commit (if any fixes needed)**
-
-```bash
-git add public/nodes.js public/packets.js public/channels.js
-git commit -m "fix: deep linking E2E adjustments (#536)"
-```
-
----
-
-## Self-Review
-
-**Spec coverage check:**
-- ✅ P1: Nodes role tab → Task 2
-- ✅ P1: Packets time window → Task 3
-- ✅ P1: Packets region filter → Task 3 (depends on Task 1)
-- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351)
-- ✅ P1: Channels node panel → Task 4
-- ✅ P2+ items → explicitly out of scope per issue
-
-**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll).
-
-**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅
-
-**Type consistency:**
-- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅
-- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅
-- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅
diff --git a/docs/superpowers/specs/2026-04-23-scope-stats-design.md b/docs/superpowers/specs/2026-04-23-scope-stats-design.md
deleted file mode 100644
index 22a310bd..00000000
--- a/docs/superpowers/specs/2026-04-23-scope-stats-design.md
+++ /dev/null
@@ -1,204 +0,0 @@
-# Scope Stats Page — Design Spec
-
-**Issue**: Kpa-clawbot/CoreScope#899
-**Date**: 2026-04-23
-**Branch target**: `master`
-
----
-
-## Overview
-
-Add a dedicated **Scopes** page showing scope/region statistics for MeshCore transport-route packets. Scope filtering in MeshCore uses `TRANSPORT_FLOOD` (route_type 0) and `TRANSPORT_DIRECT` (route_type 3) packets that carry two 16-bit transport codes. Code1 ≠ `0000` means the packet is region-scoped.
-
-Feature 3 from the issue (default scope per client via advert) is **not implemented** — the advert format has no scope field in the current firmware.
-
----
-
-## How Scopes Work (Firmware)
-
-Transport code derivation (authoritative source: `meshcore-dev/MeshCore`):
-
-```
-key = SHA256("#regionname")[:16] // TransportKeyStore::getAutoKeyFor
-Code1 = HMAC-SHA256(key, type || payload) // TransportKey::calcTransportCode, 2-byte output
-```
-
-Code1 is a **per-message** HMAC — the same region produces a different Code1 for every message. Identifying a region from Code1 requires knowing the region name in advance and recomputing the HMAC.
-
-`Code1 = 0000` is the "no scope" sentinel (also `FFFF` is reserved). Packets with route_type 1 or 2 (plain FLOOD/DIRECT) carry no transport codes.
-
----
-
-## Config
-
-Add `hashRegions` to the ingestor `Config` struct in `cmd/ingestor/config.go`, mirroring `hashChannels`:
-
-```json
-"hashRegions": ["#belgium", "#eu", "#brussels"]
-```
-
-Normalization (same rules as `hashChannels`):
-- Trim whitespace
-- Prepend `#` if missing
-- Skip empty entries
-
----
-
-## Ingestor Changes
-
-### Key derivation (`loadRegionKeys`)
-
-```go
-func loadRegionKeys(cfg *Config) map[string][]byte {
- // key = first 16 bytes of SHA256("#regionname")
-}
-```
-
-Returns `map[string][]byte` (region name → 16-byte HMAC key). Called once at startup, stored on the `Store`.
-
-### Decoder: expose raw payload bytes
-
-Add `PayloadRaw []byte` to `DecodedPacket` in `cmd/ingestor/decoder.go`. Populated from the raw `buf` slice at the payload offset — zero-copy slice, no allocation. This is the **encrypted** payload bytes, matching what the firmware feeds into `calcTransportCode`.
-
-### At-ingest region matching
-
-In `BuildPacketData`:
-- Skip if `route_type` not in `{0, 3}` → `scope_name` stays `nil`
-- If `Code1 == "0000"` → `scope_name = nil` (unscoped transport, no scope involvement)
-- If `Code1 != "0000"` → try each region key:
- ```
- HMAC-SHA256(key, payloadType_byte || PayloadRaw) → first 2 bytes as uint16
- ```
- First match → `scope_name = "#regionname"`. No match → `scope_name = ""` (unknown scope).
-
-Add `ScopeName *string` to `PacketData`.
-
-### MQTT-sourced packets (DM / CHAN paths in main.go)
-
-These are injected directly without going through `BuildPacketData`. They use `route_type = 1` (FLOOD), so they are never transport-route packets. No scope matching needed for these paths.
-
----
-
-## Database
-
-### Migration
-
-```sql
-ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL;
-CREATE INDEX idx_tx_scope_name ON transmissions(scope_name) WHERE scope_name IS NOT NULL;
-```
-
-### Column semantics
-
-| Value | Meaning |
-|-------|---------|
-| `NULL` | Either: non-transport-route packet (route_type 1/2), or transport-route with Code1=0000 |
-| `""` (empty string) | Transport-route, Code1 ≠ 0000, but no configured region matched |
-| `"#belgium"` | Matched named region |
-
-The API stats queries resolve the NULL ambiguity by always filtering `route_type IN (0, 3)` first:
-- `unscoped` count = `route_type IN (0,3) AND scope_name IS NULL`
-- `scoped` count = `route_type IN (0,3) AND scope_name IS NOT NULL`
-
-### Backfill
-
-On migration, re-decode `raw_hex` for all rows where `route_type IN (0, 3)` and `scope_name IS NULL`. Run the same HMAC matching logic. Rows with `Code1 = 0000` remain `NULL`.
-
-The backfill runs in the existing migration framework in `cmd/ingestor/db.go`. If no regions are configured, backfill is skipped.
-
----
-
-## API
-
-### `GET /api/scope-stats`
-
-**Query param**: `window` — one of `1h`, `24h` (default), `7d`
-
-**Time-series bucket sizes**:
-| Window | Bucket |
-|--------|--------|
-| `1h` | 5 min |
-| `24h` | 1 hour |
-| `7d` | 6 hours|
-
-**Response**:
-```json
-{
- "window": "24h",
- "summary": {
- "transportTotal": 1240,
- "scoped": 890,
- "unscoped": 350,
- "unknownScope": 42
- },
- "byRegion": [
- { "name": "#belgium", "count": 612 },
- { "name": "#eu", "count": 236 }
- ],
- "timeSeries": [
- { "t": "2026-04-23T10:00:00Z", "scoped": 45, "unscoped": 18 },
- { "t": "2026-04-23T11:00:00Z", "scoped": 51, "unscoped": 22 }
- ]
-}
-```
-
-- `transportTotal` = `scoped + unscoped` (transport-route packets only)
-- `scoped` = Code1 ≠ 0000 (named + unknown)
-- `unscoped` = transport-route with Code1 = 0000
-- `unknownScope` = scoped but no region name matched (subset of `scoped`)
-- `byRegion` sorted by count descending, excludes unknown
-- `timeSeries` covers the full window at the bucket granularity
-
-Route: `GET /api/scope-stats` registered in `cmd/server/routes.go`.
-No auth required (same as other read endpoints).
-TTL cache: 30 seconds (heavier query than `/api/stats`).
-
----
-
-## Frontend
-
-### Navigation
-
-Add nav link between Channels and Nodes in `public/index.html`:
-```html
-Scopes
-```
-
-### `public/scopes.js`
-
-Three sections on the page:
-
-**1. Summary cards** (reuse existing card CSS pattern from home/analytics pages)
-- Transport total, Scoped, Unscoped, Unknown scope
-- Each card shows count + percentage of transport total
-
-**2. Per-region table**
-Columns: Region, Messages, % of Scoped
-Sorted by count descending. Last row: "Unknown scope" (italic) if unknownScope > 0.
-Shows "No regions configured" message if `byRegion` is empty and `unknownScope = 0`.
-
-**3. Time-series chart**
-- Window selector: `1h / 24h / 7d` (default 24h)
-- Two lines: **Scoped** (blue) and **Unscoped** (grey)
-- Uses the same lightweight canvas chart pattern as other pages (no external chart lib)
-
-### Cache buster
-
-`scopes.js` added to the `__BUST__` entries in `index.html` in the same commit.
-
----
-
-## Testing
-
-- Unit tests for `loadRegionKeys`: normalization, key bytes match firmware SHA256 derivation
-- Unit tests for HMAC matching: known Code1 value computed from firmware logic, verified against Go implementation
-- Integration test: ingest a synthetic transport-route packet with a known region, assert `scope_name` column is set correctly
-- API test: `GET /api/scope-stats` returns correct summary counts against fixture DB
-
----
-
-## Out of Scope
-
-- Feature 3 (default scope per client via advert) — firmware has no advert scope field
-- Drill-down from region row to filtered packet list (deferred)
-- Private regions (`$`-prefixed) — use secret keys not publicly derivable
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 7ff59d94..7435fd3f 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -206,7 +206,9 @@ Provide cert and key paths to enable HTTPS.
Restricts ingestion and API responses to nodes within the polygon plus a buffer margin. Remove the block to disable filtering. Nodes with no GPS fix always pass through.
-See [Geographic Filtering](geofilter.md) for the full guide including the visual polygon builder and the prune script for cleaning up historical data.
+Can also be configured live via the **🗺️ GeoFilter** tab in the Customizer (requires `apiKey`).
+
+See [Geographic Filtering](geofilter.md) for the full guide.
## Home page
diff --git a/docs/user-guide/customization.md b/docs/user-guide/customization.md
index 888761e2..c4d982de 100644
--- a/docs/user-guide/customization.md
+++ b/docs/user-guide/customization.md
@@ -66,11 +66,13 @@ Click **Import JSON** and paste a previously exported theme. The customizer load
Click **Reset to Defaults** to restore all settings to the built-in defaults.
-## GeoFilter Builder
+## GeoFilter (admin only)
-The Export tab includes a **GeoFilter Builder →** link. Click it to open a Leaflet map where you can draw a polygon boundary for your deployment area. The tool generates a `geo_filter` block you can paste directly into `config.json`.
+The **🗺️ GeoFilter** tab lets operators configure geographic filtering directly from the customizer. It shows the active polygon on a Leaflet map and — on servers with a write-capable `apiKey` — allows editing the polygon and saving back to `config.json` without a restart.
-See [Geographic Filtering](geofilter.md) for full details on what geo filtering does and how to configure it.
+The editing controls are only revealed after the server confirms write access. On public deployments without an `apiKey`, the tab is read-only.
+
+See [Geographic Filtering](geofilter.md) for the full guide, including the API, the prune script, and the standalone GeoFilter Builder.
## How it works
diff --git a/docs/user-guide/geofilter.md b/docs/user-guide/geofilter.md
index 27d20b41..f17d7a62 100644
--- a/docs/user-guide/geofilter.md
+++ b/docs/user-guide/geofilter.md
@@ -30,9 +30,9 @@ Add a `geo_filter` block to `config.json`:
| Field | Type | Description |
|-------|------|-------------|
| `polygon` | `[[lat, lon], ...]` | Array of at least 3 coordinate pairs defining the boundary |
-| `bufferKm` | number | Extra distance (km) around the polygon edge that is also accepted. `0` = exact boundary |
+| `bufferKm` | number | Extra distance (km) outside the polygon edge that is also accepted. `0` = exact boundary |
-Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section.
+Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section manually.
To disable filtering entirely, remove the `geo_filter` block.
@@ -51,50 +51,64 @@ An older bounding box format is also supported as a fallback when no `polygon` i
Prefer the polygon format — it supports irregular shapes and the `bufferKm` margin.
-## API endpoint
+## Configuring via the customizer
-The current geo filter configuration is exposed at:
+If your server has an `apiKey` configured, the **GeoFilter tab** in the Customizer lets you edit the polygon visually without touching `config.json`:
-```
-GET /api/config/geo-filter
-```
+1. Open the Customizer (nav bar → customize icon)
+2. Click the **🗺️ GeoFilter** tab
+3. Click on the map to draw your polygon (at least 3 points)
+4. Adjust **Buffer km**
+5. Enter your **Server API Key** (the `apiKey` value from `config.json`)
+6. Click **Save to server** — the filter is applied immediately, no restart needed
-The frontend reads this endpoint to display the active filter. No authentication is required (the endpoint returns config, not private data).
+The editing controls are always visible. Saving requires entering the server's `apiKey`; on servers without one (or with a weak key), the save request returns `401`/`403` and the error is shown inline.
-## GeoFilter Builder
+To remove the filter, click **Remove filter** (also requires the API key).
-The simplest way to create a polygon is the included visual builder:
+## GeoFilter Builder (standalone tool)
-**File:** `tools/geofilter-builder.html`
+For a full-screen editing experience, use the built-in GeoFilter Builder at `/geofilter-builder.html`:
-Open it directly in a browser — it runs entirely client-side, no server required:
+1. Navigate to `http://your-server/geofilter-builder.html`
+2. Click on the map to add polygon vertices
+3. Adjust **Buffer km** (default 20)
+4. Copy the generated JSON from the output panel
+5. Paste it as a top-level key into `config.json` and restart the server
+
+The builder is also accessible from the Customizer's Export tab via the **GeoFilter Builder →** link.
+
+For local/offline use without a running server, open `tools/geofilter-builder.html` directly in a browser.
+
+## API endpoint
-```bash
-# From the project root
-open tools/geofilter-builder.html # macOS
-xdg-open tools/geofilter-builder.html # Linux
-start tools/geofilter-builder.html # Windows
+```
+GET /api/config/geo-filter
+```
+
+Returns the current geo filter configuration (`polygon`, `bufferKm`). Whether the `PUT` endpoint will accept a write depends on whether the server has an `apiKey` configured; clients should attempt the write and handle `401`/`403` if it isn't.
+
+```
+PUT /api/config/geo-filter
```
-**Workflow:**
+Requires `X-API-Key` header. Saves the polygon to `config.json` and applies it in-memory immediately.
-1. The map opens centered on Belgium by default. Navigate to your region.
-2. Click on the map to add polygon vertices. Each click adds a numbered point.
-3. Add at least 3 points to form a closed polygon.
-4. Adjust **Buffer km** (default 20) to add a margin around the polygon edge.
-5. The generated JSON block appears at the bottom of the page — copy it directly into `config.json`.
-6. Use **↩ Undo** to remove the last point, **✕ Clear** to start over.
+Request body:
+```json
+{"polygon": [[lat, lon], ...], "bufferKm": 20}
+```
-The output is a complete `{ "geo_filter": { ... } }` block ready to paste into `config.json`.
+To clear the filter, send `{"polygon": null}`.
## Cleaning up historical nodes
-The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes that were stored before the filter was configured. For that, use the prune script.
+The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes stored before the filter was configured. For that, use the prune script:
**File:** `scripts/prune-nodes-outside-geo-filter.py`
```bash
-# Dry run — shows what would be deleted without making any changes
+# Dry run — shows what would be deleted without making changes
python3 scripts/prune-nodes-outside-geo-filter.py --dry-run
# Default paths: /app/data/meshcore.db and /app/config.json
@@ -104,11 +118,11 @@ python3 scripts/prune-nodes-outside-geo-filter.py
python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \
--config /path/to/config.json
-# In Docker — run inside the container
+# In Docker
docker exec -it meshcore-analyzer \
python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run
```
-The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists the nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept.
+The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept.
-This is a **one-time migration tool** — run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically at ingest time.
+This is a **one-time migration tool** — run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically.
diff --git a/public/customize-v2.js b/public/customize-v2.js
index 3e50d532..1e9ef4c9 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -878,6 +878,16 @@
var _activeTab = 'branding';
var _styleEl = null;
+ // GeoFilter tab state
+ var _gfMap = null;
+ var _gfModalMap = null;
+ var _gfWriteEnabled = false;
+ var _gfPoints = [];
+ var _gfMarkers = [];
+ var _gfPolygon = null;
+ var _gfClosingLine = null;
+ var _gfLoaded = false; // true after initial server load
+
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/' + n + '' : ''; })() },
{ id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') },
{ id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' ' + n + '' : ''; })() },
+ { id: 'geofilter', label: '🗺️', title: 'GeoFilter' },
{ id: 'export', label: '📤', title: 'Export' }
];
return '
';
_bindEvents(container);
@@ -1358,11 +1657,15 @@
// Tab switching
container.querySelectorAll('.cust-tab').forEach(function (btn) {
btn.addEventListener('click', function () {
+ if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
_activeTab = btn.dataset.tab;
_renderPanel(container);
});
});
+ // GeoFilter tab init
+ if (_activeTab === 'geofilter') _initGeoFilterTab(container);
+
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -1645,7 +1948,10 @@
'';
document.body.appendChild(_panelEl);
- _panelEl.querySelector('.cust-close').addEventListener('click', function () { _panelEl.classList.add('hidden'); });
+ _panelEl.querySelector('.cust-close').addEventListener('click', function () {
+ if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
+ _panelEl.classList.add('hidden');
+ });
// Drag support
var header = _panelEl.querySelector('.cust-header');
diff --git a/public/geofilter-builder.html b/public/geofilter-builder.html
index 1d970118..a34d0449 100644
--- a/public/geofilter-builder.html
+++ b/public/geofilter-builder.html
@@ -8,18 +8,18 @@
@@ -79,14 +79,14 @@
GeoFilter Builder
Save Draft preserves your polygon across sessions. Download exports a JSON snippet → paste as a top-level key in config.json → restart the server.
Nodes with no GPS fix always pass through. Remove the geo_filter block to disable filtering.
- · Documentation
+ · Documentation ↗