Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions cmd/server/declared_regions_bulk.go
Original file line number Diff line number Diff line change
@@ -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)})
}
46 changes: 46 additions & 0 deletions cmd/server/declared_regions_bulk_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/server/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
Expand Down
1 change: 1 addition & 0 deletions cmd/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions docs/client-rx-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<companion name>",
Expand Down
Loading