From d05d7489e47ce24036226074d861ebd21f399644 Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 16 Oct 2026 21:23:46 +0200 Subject: [PATCH] feat(server): GET /api/nodes/declared-regions, every repeater's flood regions in one response CoreDrive RX picks the scope of its uplink pings from the regions the repeaters around it forward. /api/nodes already carries declared_regions per node, but only by paging through every node. This endpoint returns the newest answer per repeater (declaredRegionsCSV, cached 30 s): named regions without "#", wildcard for "*", truncated when the reply was cut off. Co-Authored-By: Claude Opus 5.5 --- cmd/server/declared_regions_bulk.go | 48 ++++++++++++++++++++++++ cmd/server/declared_regions_bulk_test.go | 46 +++++++++++++++++++++++ cmd/server/openapi.go | 1 + cmd/server/routes.go | 1 + docs/client-rx-coverage.md | 4 ++ 5 files changed, 100 insertions(+) create mode 100644 cmd/server/declared_regions_bulk.go create mode 100644 cmd/server/declared_regions_bulk_test.go diff --git a/cmd/server/declared_regions_bulk.go b/cmd/server/declared_regions_bulk.go new file mode 100644 index 000000000..df82dccd4 --- /dev/null +++ b/cmd/server/declared_regions_bulk.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "sort" +) + +// GET /api/nodes/declared-regions: every repeater's newest declared region +// list in one compact response, for a mobile client choosing the flood scope +// of what it sends (CoreDrive RX uplink pings). The per-node fields on +// /api/nodes carry the same answer, but a client would have to page through +// every node to collect them. +// +// regions are the flood-allowed named regions without "#" (splitDeclaredRegions); +// wildcard says the repeater also floods unscoped packets ("*"). + +type DeclaredRegionsEntry struct { + PubKey string `json:"pubkey"` + Regions []string `json:"regions"` + Wildcard bool `json:"wildcard"` + Truncated bool `json:"truncated,omitempty"` +} + +type DeclaredRegionsResponse struct { + Nodes []DeclaredRegionsEntry `json:"nodes"` +} + +func declaredRegionsEntries(answers map[string]declaredAnswer) []DeclaredRegionsEntry { + out := make([]DeclaredRegionsEntry, 0, len(answers)) + for pk, a := range answers { + named, wildcard := splitDeclaredRegions(a.RegionsCSV) + out = append(out, DeclaredRegionsEntry{PubKey: pk, Regions: named, Wildcard: wildcard, Truncated: a.Truncated}) + } + sort.Slice(out, func(i, j int) bool { return out[i].PubKey < out[j].PubKey }) + return out +} + +// handleDeclaredRegions answers an empty list when this database carries no +// declared source, or when the lookup failed (logged by declaredRegionsCSV): +// a client then keeps its own fallback scope. +func (s *Server) handleDeclaredRegions(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.conn == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable") + return + } + answers, _ := s.declaredRegionsCSV() + writeJSON(w, DeclaredRegionsResponse{Nodes: declaredRegionsEntries(answers)}) +} diff --git a/cmd/server/declared_regions_bulk_test.go b/cmd/server/declared_regions_bulk_test.go new file mode 100644 index 000000000..da6f83030 --- /dev/null +++ b/cmd/server/declared_regions_bulk_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +func TestDeclaredRegionsEndpoint(t *testing.T) { + srv, router := setupScopeAuditServer(t) + router.HandleFunc("/api/nodes/declared-regions", srv.handleDeclaredRegions).Methods("GET") + get := func() DeclaredRegionsResponse { + t.Helper() + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/declared-regions", nil)) + if rr.Code != 200 { + t.Fatalf("status %d", rr.Code) + } + var resp DeclaredRegionsResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp + } + if resp := get(); resp.Nodes == nil || len(resp.Nodes) != 0 { + t.Fatalf("no declared source: %+v, want an empty list", resp) + } + + createDeclaredRegionsTable(t, srv) + mustExecDB(t, srv.db, `INSERT INTO node_declared_regions (target, rx_pubkey, observed_at, ingested_at, regions_csv, truncated) VALUES + ('AABB01', 'c1', '2026-10-16T10:00:00Z', 'x', '*,be,nl', 0), + ('aabb01', 'c2', '2026-10-16T12:00:00Z', 'x', '*,#nl,nl-li', 1), + ('ccdd02', 'c1', '2026-10-16T11:00:00Z', 'x', 'be', 0)`) + srv.declaredRegionsCache = nil + resp := get() + if len(resp.Nodes) != 2 { + t.Fatalf("entries = %+v, want one per repeater", resp.Nodes) + } + a, c := resp.Nodes[0], resp.Nodes[1] + if a.PubKey != "aabb01" || !a.Wildcard || !a.Truncated || len(a.Regions) != 2 || a.Regions[0] != "nl" || a.Regions[1] != "nl-li" { + t.Errorf("aabb01 = %+v, want its newest answer, lowercased key, # stripped, * as wildcard", a) + } + if c.PubKey != "ccdd02" || c.Wildcard || len(c.Regions) != 1 || c.Regions[0] != "be" { + t.Errorf("ccdd02 = %+v, want be only, no wildcard", c) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 97f3946de..3b1d73dbf 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -142,6 +142,7 @@ func routeDescriptions() map[string]routeMeta { }}, "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, "GET /api/nodes/reported": {Summary: "Repeaters known only from their reported name", Description: "Repeaters that told a mobile companion their own name (client_node_names, ANON_REQ_TYPE_OWNER) and have no advert position in nodes. Each carries its reception counts and, when companions heard it directly from at least `method.min_spots` spots at least `method.min_spread_m` apart, an `estimate`: a SNR-weighted centroid of those spots with `radius_m` (never below `method.min_radius_m`). The estimate is where companions stood, weighted, not a measured position. Cached for 5 minutes.", Tag: "nodes"}, + "GET /api/nodes/declared-regions": {Summary: "Every repeater's declared flood regions", Description: "One entry per repeater with a declared-regions answer (node_declared_regions, ANON_REQ_TYPE_REGIONS), newest answer per repeater: `regions` are the flood-allowed named regions without `#`, `wildcard` is true when the repeater also floods unscoped packets (`*`), `truncated` when the reply was cut off. The same answer as `declared_regions` on /api/nodes, in one response, for a mobile client choosing the flood scope of what it sends. Empty when this instance carries no declared source. Cached for 30 seconds.", Tag: "nodes"}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index d852da54f..98717c4f6 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -351,6 +351,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/uplink-coverage", s.handleUplinkCoverage).Methods("GET") // gated by requireClientUplink r.HandleFunc("/api/nodes/resolve", s.handleResolvePrefix).Methods("GET") r.HandleFunc("/api/nodes/reported", s.handleReportedNodes).Methods("GET") + r.HandleFunc("/api/nodes/declared-regions", s.handleDeclaredRegions).Methods("GET") r.HandleFunc("/api/rx-coverage", s.handleRxCoverage).Methods("GET") r.HandleFunc("/api/rx-leaderboard", s.handleRxLeaderboard).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET") diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index dab002a1a..a60f960b2 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -128,6 +128,10 @@ the observer `/neighbors` source (`nodes.configured_scope`). Newest answer per r both, by the answer's own `timestamp`, so a buffered upload arriving late cannot overwrite a fresher one. +`GET /api/nodes/declared-regions` returns that newest answer for every repeater in one response +(`{pubkey, regions, wildcard, truncated}`), so a companion can choose the flood scope of what it +sends from what the repeaters around it actually forward (see the uplink pings below). + ```json { "origin": "",