diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index ac09ebeb..65fadf05 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -245,13 +245,19 @@ func (s *PacketStore) LoadChunked(chunkSize int) error { if s.db.hasObsRawHex { obsRawHexCol = ", o.raw_hex" } + // #1751: scope_name is on the transmission row, so appending it as the + // last selected column is safe regardless of the observation fan-out. + scopeNameCol := "" + if s.db.hasScopeName { + scopeNameCol = ", t.scope_name" + } var chunkSQL string if s.db.isV3 { chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + ` FROM (SELECT * FROM transmissions t2 ` + whereClause + ` ORDER BY t2.id ASC LIMIT ` + fmt.Sprintf("%d", chunkSize) + `) AS t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx @@ -260,7 +266,7 @@ func (s *PacketStore) LoadChunked(chunkSize int) error { chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + ` FROM (SELECT * FROM transmissions t2 ` + whereClause + ` ORDER BY t2.id ASC LIMIT ` + fmt.Sprintf("%d", chunkSize) + `) AS t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.id = o.observer_id @@ -373,6 +379,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold var score sql.NullInt64 var obsRawHex sql.NullString var resolvedPathStr sql.NullString + var scopeName sql.NullString scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &payloadVersion, &decodedJSON, @@ -384,6 +391,9 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold if s.db.hasResolvedPath { scanArgs = append(scanArgs, &resolvedPathStr) } + if s.db.hasScopeName { + scanArgs = append(scanArgs, &scopeName) + } if err := rows.Scan(scanArgs...); err != nil { log.Printf("[store] LoadChunked scan error: %v", err) continue @@ -406,6 +416,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), + ScopeName: nullStrVal(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } diff --git a/cmd/server/repeater_enrich_bulk.go b/cmd/server/repeater_enrich_bulk.go index 5c981c00..e063cd4d 100644 --- a/cmd/server/repeater_enrich_bulk.go +++ b/cmd/server/repeater_enrich_bulk.go @@ -111,6 +111,12 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin out := make(map[string]RepeaterRelayInfo, len(snap)) for key, list := range snap { info := RepeaterRelayInfo{WindowHours: windowHours} + // #1751: accumulate the set of region scope names carried by this + // hop key across every non-advert path-hop tx (NOT time-windowed). + // Captured by the visit closure below — lazily allocated on the first + // scope hit so hosts without scope_name pay nothing per key; converted + // to a sorted, capped slice before this key's info is stored. + var scopeSet map[string]struct{} // When key looks like a full pubkey (>= 2 hex chars), also fold // in the matching 1-byte raw-prefix bucket to mirror // GetRepeaterRelayInfo's behavior. We dedup by tx ID. @@ -141,6 +147,17 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin if p.pt == payloadTypeAdvert { continue } + // #1751: scope accumulation is intentionally NOT gated on + // p.ok (timestamp parseability) — a packet with an + // unparseable first_seen still proves the repeater + // transported that scope. RelayCount/LastRelayed below + // remain timestamp-gated. + if tx.ScopeName != "" { + if scopeSet == nil { + scopeSet = map[string]struct{}{} + } + scopeSet[tx.ScopeName] = struct{}{} + } if !p.ok { continue } @@ -167,6 +184,7 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin visit(snap[prefix]) } } + info.TransportedScopes = sortedCappedScopes(scopeSet) out[key] = info } return out diff --git a/cmd/server/repeater_liveness.go b/cmd/server/repeater_liveness.go index 7c6086cb..8238d5f1 100644 --- a/cmd/server/repeater_liveness.go +++ b/cmd/server/repeater_liveness.go @@ -1,6 +1,7 @@ package main import ( + "sort" "strings" "time" ) @@ -26,6 +27,40 @@ type RepeaterRelayInfo struct { // RelayCount24h is the count of distinct non-advert packets where this // pubkey appeared as a relay hop in the last 24 hours. RelayCount24h int `json:"relayCount24h"` + // TransportedScopes is the deduplicated, sorted set of region scope + // names (transmissions.scope_name) across ALL non-advert packets in + // which this pubkey appears as a path hop. Unlike RelayCount1h/24h this + // is NOT time-windowed — it answers "which region scopes has this + // repeater carried traffic for, ever (within the in-memory window)". + // Empty/absent on schemas without scope_name (#1751). + TransportedScopes []string `json:"transportedScopes,omitempty"` +} + +// maxTransportedScopes bounds the per-node TransportedScopes list so a +// misbehaving sender flooding distinct scope_name values through a single +// repeater cannot inflate the node JSON unboundedly (#1751 review follow-up). +// Real region-scope counts are small; this is a defensive ceiling. When the +// set exceeds the cap the lexicographically-first names are kept, so the +// result stays deterministic. +const maxTransportedScopes = 32 + +// sortedCappedScopes converts a scope set into a sorted, length-capped slice, +// or nil when the set is empty/nil — so routes.go omits the JSON field via +// `omitempty`. Shared by the bulk (computeRepeaterRelayInfoMap) and per-node +// (computeRelayInfoFromEntries) paths to keep them in exact parity. +func sortedCappedScopes(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + scopes := make([]string, 0, len(set)) + for s := range set { + scopes = append(scopes, s) + } + sort.Strings(scopes) + if len(scopes) > maxTransportedScopes { + scopes = scopes[:maxTransportedScopes] + } + return scopes } // payloadTypeAdvert is the MeshCore payload type for ADVERT packets. @@ -62,6 +97,9 @@ func parseRelayTS(ts string) (time.Time, bool) { type relayEntry struct { ts string pt int + // scope is the tx's region scope name (transmissions.scope_name). + // Empty when absent / on older schemas. Used for TransportedScopes (#1751). + scope string } // collectRelayEntriesLocked returns deduplicated relayEntry snapshots for @@ -105,7 +143,7 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { if tx.PayloadType != nil { pt = *tx.PayloadType } - entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt}) + entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, scope: tx.ScopeName}) } } collect(txList) @@ -124,11 +162,21 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe var latest time.Time var latestRaw string + var scopeSet map[string]struct{} for _, e := range entries { // Self-originated adverts are not relay activity. if e.pt == payloadTypeAdvert { continue } + // #1751: accumulate transported scopes BEFORE the timestamp gate — + // a non-advert path-hop tx proves scope transport even if its + // first_seen is unparseable. Mirrors the bulk path. + if e.scope != "" { + if scopeSet == nil { + scopeSet = map[string]struct{}{} + } + scopeSet[e.scope] = struct{}{} + } t, ok := parseRelayTS(e.ts) if !ok { continue @@ -144,6 +192,9 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe } } } + // #1751: emit transported scopes regardless of whether any timestamp + // parsed, and before the latestRaw early-return below. + info.TransportedScopes = sortedCappedScopes(scopeSet) if latestRaw == "" { return info } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7a43184a..0719090f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1353,6 +1353,12 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { node["relay_active"] = info.RelayActive node["relay_count_1h"] = info.RelayCount1h node["relay_count_24h"] = info.RelayCount24h + // #1751: region scopes this repeater has transported. + // Set only when non-empty so the field is absent for + // nodes without scopes / on older schemas. + if len(info.TransportedScopes) > 0 { + node["transported_scopes"] = info.TransportedScopes + } // usefulness_score retained for API compat; new // consumers should read traffic_share_score // (issue #1456). When the #672 composite ships @@ -1539,6 +1545,11 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { node["relay_window_hours"] = info.WindowHours node["relay_count_1h"] = info.RelayCount1h node["relay_count_24h"] = info.RelayCount24h + // #1751: region scopes this repeater has transported. Set only + // when non-empty (absent for no-scope nodes / older schemas). + if len(info.TransportedScopes) > 0 { + node["transported_scopes"] = info.TransportedScopes + } // usefulness_score retained for API compat; new // consumers should read traffic_share_score (#1456). us := s.store.GetRepeaterUsefulnessScore(pubkey) diff --git a/cmd/server/store.go b/cmd/server/store.go index a0f87e7f..1e4d642a 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -37,6 +37,10 @@ type StoreTx struct { RouteType *int PayloadType *int DecodedJSON string + // ScopeName is the transmission's region scope name (transmissions.scope_name, + // #899). Empty on schemas without the column (db.hasScopeName=false). Used to + // surface the set of region scopes a repeater has transported (#1751). + ScopeName string Observations []*StoreObs ObservationCount int // Display fields from longest-path observation @@ -704,6 +708,12 @@ func (s *PacketStore) Load() error { if s.db.hasObsRawHex { obsRawHexCol = ", o.raw_hex" } + // #1751: scope_name is on the transmission row; append as the last + // selected column so the observation fan-out doesn't affect its position. + scopeNameCol := "" + if s.db.hasScopeName { + scopeNameCol = ", t.scope_name" + } // Build WHERE conditions: retention cutoff (mirrors Evict logic) + optional memory-cap limit. // When hotStartupHours > 0, use it as the initial cutoff (smaller window = fast startup). @@ -748,7 +758,7 @@ func (s *PacketStore) Load() error { loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + filterClause + ` @@ -757,7 +767,7 @@ func (s *PacketStore) Load() error { loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.id = o.observer_id` + filterClause + ` @@ -795,6 +805,7 @@ func (s *PacketStore) Load() error { var score sql.NullInt64 var obsRawHex sql.NullString var resolvedPathStr sql.NullString + var scopeName sql.NullString scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &payloadVersion, &decodedJSON, @@ -806,6 +817,9 @@ func (s *PacketStore) Load() error { if s.db.hasResolvedPath { scanArgs = append(scanArgs, &resolvedPathStr) } + if s.db.hasScopeName { + scanArgs = append(scanArgs, &scopeName) + } if err := rows.Scan(scanArgs...); err != nil { log.Printf("[store] scan error: %v", err) continue @@ -823,6 +837,7 @@ func (s *PacketStore) Load() error { RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), + ScopeName: nullStrVal(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } @@ -1026,6 +1041,11 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { if s.db.hasObsRawHex { obsRawHexCol = ", o.raw_hex" } + // #1751: scope_name is on the transmission row; append as the last column. + scopeNameCol := "" + if s.db.hasScopeName { + scopeNameCol = ", t.scope_name" + } // #1690: window on the denormalized last_seen (effective recency) // rather than first_seen. See chunked_load.go for the full rationale. @@ -1045,7 +1065,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, obs.id, obs.name, o.direction, - o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + filterClause + ` @@ -1054,7 +1074,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, o.observer_id, o.observer_name, o.direction, - o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + ` + o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id` + filterClause + ` ORDER BY t.first_seen ASC, o.timestamp DESC` @@ -1114,6 +1134,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { var score sql.NullInt64 var obsRawHex sql.NullString var resolvedPathStr sql.NullString + var scopeName sql.NullString scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &payloadVersion, &decodedJSON, @@ -1125,6 +1146,9 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { if s.db.hasResolvedPath { scanArgs = append(scanArgs, &resolvedPathStr) } + if s.db.hasScopeName { + scanArgs = append(scanArgs, &scopeName) + } if err := rows.Scan(scanArgs...); err != nil { log.Printf("[store] loadChunk scan error: %v", err) continue @@ -1142,6 +1166,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { RouteType: nullIntPtr(routeType), PayloadType: nullIntPtr(payloadType), DecodedJSON: nullStrVal(decodedJSON), + ScopeName: nullStrVal(scopeName), obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } @@ -2427,11 +2452,16 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac if s.db.hasObsRawHex { obsRHCol = ", o.raw_hex" } + // #1751: scope_name is on the transmission row; append as the last column. + scopeNameCol := "" + if s.db.hasScopeName { + scopeNameCol = ", t.scope_name" + } if s.db.isV3 { querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + ` + o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx @@ -2441,7 +2471,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction, - o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + ` + o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + scopeNameCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.id = o.observer_id @@ -2464,6 +2494,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac obsID *int observerID, observerName, observerIATA, direction, pathJSON, obsTS string obsRawHex string + scopeName string snr, rssi *float64 score *int } @@ -2481,6 +2512,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac var snrVal, rssiVal sql.NullFloat64 var scoreVal sql.NullInt64 var obsRawHex sql.NullString + var scopeName sql.NullString scanArgs2 := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &payloadVersion, &decodedJSON, @@ -2489,6 +2521,9 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac if s.db.hasObsRawHex { scanArgs2 = append(scanArgs2, &obsRawHex) } + if s.db.hasScopeName { + scanArgs2 = append(scanArgs2, &scopeName) + } if err := rows.Scan(scanArgs2...); err != nil { continue } @@ -2516,6 +2551,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac pathJSON: nullStrVal(pathJSON), obsTS: nullStrVal(obsTimestamp), obsRawHex: nullStrVal(obsRawHex), + scopeName: nullStrVal(scopeName), snr: nullFloatPtr(snrVal), rssi: nullFloatPtr(rssiVal), score: nullIntPtr(scoreVal), @@ -2570,6 +2606,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac RouteType: r.routeType, PayloadType: r.payloadType, DecodedJSON: r.decodedJSON, + ScopeName: r.scopeName, obsKeys: make(map[string]bool), observerSet: make(map[string]bool), } diff --git a/cmd/server/transported_scopes_1751_test.go b/cmd/server/transported_scopes_1751_test.go new file mode 100644 index 00000000..49cf3706 --- /dev/null +++ b/cmd/server/transported_scopes_1751_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "reflect" + "strconv" + "sync" + "testing" + "time" +) + +// Issue #1751: repeater/room nodes should expose the deduplicated, sorted +// set of region scope names (transmissions.scope_name) across every +// non-advert packet in which they appear as a path hop. Advert packets must +// be excluded (a self-advert is not transported traffic). +// +// These tests exercise BOTH computation paths that feed RepeaterRelayInfo: +// - computeRepeaterRelayInfoMap (bulk, repeater_enrich_bulk.go) +// - GetRepeaterRelayInfo (per-node, repeater_liveness.go) +// so the field stays in parity for /api/nodes (bulk) and the single-node +// detail endpoint (per-node). + +const scope1751Key = "aabbccdd11223344" + +// scopeTx builds a path-hop StoreTx with the given payload type, scope name, +// and an in-window FirstSeen. +func scopeTx(id int, payloadType int, scope string) *StoreTx { + pt := payloadType + return &StoreTx{ + ID: id, + Hash: "scope-tx-" + scope + "-" + strconv.Itoa(id), + PayloadType: &pt, + ScopeName: scope, + FirstSeen: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339Nano), + } +} + +func TestTransportedScopes_BulkDedupSortAndAdvertExcluded(t *testing.T) { + // Three non-advert packets across two distinct scopes (one repeated to + // prove dedup) PLUS one advert carrying a scope that must NOT appear. + txMsgWest := scopeTx(1, 2, "region-west") // TXT_MSG + txGrpEast := scopeTx(2, 5, "region-east") // GRP_TXT + txMsgWest2 := scopeTx(3, 1, "region-west") // REQ, duplicate scope + advertSecret := scopeTx(4, payloadTypeAdvert, "advert-only-scope") + + store := &PacketStore{ + byPathHop: map[string][]*StoreTx{ + scope1751Key: {txMsgWest, txGrpEast, txMsgWest2, advertSecret}, + }, + mu: sync.RWMutex{}, + } + + out := store.computeRepeaterRelayInfoMap(24) + got := out[scope1751Key].TransportedScopes + + want := []string{"region-east", "region-west"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("bulk TransportedScopes = %v, want %v (deduped, sorted, advert-excluded)", got, want) + } +} + +func TestTransportedScopes_PerNodeMatchesBulk(t *testing.T) { + txMsgWest := scopeTx(1, 2, "region-west") + txGrpEast := scopeTx(2, 5, "region-east") + advertSecret := scopeTx(3, payloadTypeAdvert, "advert-only-scope") + + store := &PacketStore{ + byPathHop: map[string][]*StoreTx{ + scope1751Key: {txMsgWest, txGrpEast, advertSecret}, + }, + mu: sync.RWMutex{}, + } + + info := store.GetRepeaterRelayInfo(scope1751Key, 24) + got := info.TransportedScopes + + want := []string{"region-east", "region-west"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("per-node TransportedScopes = %v, want %v (deduped, sorted, advert-excluded)", got, want) + } +} + +// TestTransportedScopes_EmptyWhenNoScope guards the "field absent" contract: +// a repeater whose path-hop packets carry no scope_name (older schema / +// hasScopeName=false → ScopeName always "") must yield a nil/empty slice so +// routes.go omits the JSON field entirely. +func TestTransportedScopes_EmptyWhenNoScope(t *testing.T) { + noScope := scopeTx(1, 2, "") // non-advert but ScopeName=="" + + store := &PacketStore{ + byPathHop: map[string][]*StoreTx{scope1751Key: {noScope}}, + mu: sync.RWMutex{}, + } + + if got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes; len(got) != 0 { + t.Fatalf("bulk: expected no scopes when ScopeName empty, got %v", got) + } + if got := store.GetRepeaterRelayInfo(scope1751Key, 24).TransportedScopes; len(got) != 0 { + t.Fatalf("per-node: expected no scopes when ScopeName empty, got %v", got) + } +} + +// TestTransportedScopes_CrossBucketFold covers the bulk path's prefix fold: +// for a full-pubkey key it also folds in the matching 1-byte raw-prefix bucket +// (deduping by tx.ID). A scope seen only in the prefix bucket must surface on +// the full key, and a tx present in BOTH buckets must not be double-processed. +func TestTransportedScopes_CrossBucketFold(t *testing.T) { + full := scopeTx(1, 2, "region-direct") // only in the full-key bucket + prefixOnly := scopeTx(2, 2, "region-via-prefix") // only in the 1-byte bucket + shared := scopeTx(3, 2, "region-shared") // in BOTH buckets (dedup by ID) + + store := &PacketStore{ + byPathHop: map[string][]*StoreTx{ + scope1751Key: {full, shared}, + scope1751Key[:2]: {prefixOnly, shared}, + }, + mu: sync.RWMutex{}, + } + + got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes + want := []string{"region-direct", "region-shared", "region-via-prefix"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("cross-bucket fold TransportedScopes = %v, want %v", got, want) + } +} + +// TestTransportedScopes_Capped pins the soft cap (#1751 review follow-up): +// more distinct scopes than maxTransportedScopes are bounded to the cap, +// keeping the lexicographically-first names so the output stays deterministic. +func TestTransportedScopes_Capped(t *testing.T) { + var txs []*StoreTx + for i := 0; i < maxTransportedScopes+10; i++ { + txs = append(txs, scopeTx(i+1, 2, fmt.Sprintf("scope-%03d", i))) + } + store := &PacketStore{ + byPathHop: map[string][]*StoreTx{scope1751Key: txs}, + mu: sync.RWMutex{}, + } + + got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes + if len(got) != maxTransportedScopes { + t.Fatalf("expected cap at %d scopes, got %d", maxTransportedScopes, len(got)) + } + if got[0] != "scope-000" || got[len(got)-1] != fmt.Sprintf("scope-%03d", maxTransportedScopes-1) { + t.Fatalf("cap should keep lexicographically-first names: first=%q last=%q", got[0], got[len(got)-1]) + } +} diff --git a/public/nodes.js b/public/nodes.js index ee66761c..84dd8fbb 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -672,6 +672,7 @@ const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint."; return `Bridge score ${bpct}% ${blabel}`; })() : ''} + ${(n.role === 'repeater' || n.role === 'room') && Array.isArray(n.transported_scopes) && n.transported_scopes.length ? `Transported scopes${n.transported_scopes.map(sc => '' + escapeHtml(String(sc)) + '').join('')}` : ''} First Seen${renderNodeTimestampHtml(n.first_seen)} Total Packets${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' (seen ' + stats.totalObservations + '×)' : ''} Packets Today${stats.packetsToday || 0}