diff --git a/cmd/server/multibyte_capability_test.go b/cmd/server/multibyte_capability_test.go index 41037930..4f77b3d8 100644 --- a/cmd/server/multibyte_capability_test.go +++ b/cmd/server/multibyte_capability_test.go @@ -85,7 +85,7 @@ func TestMultiByteCapability_Confirmed(t *testing.T) { store := NewPacketStore(db, nil) addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2)) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -123,7 +123,7 @@ func TestMultiByteCapability_Suspected(t *testing.T) { } addTestPacket(store, pkt) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -152,7 +152,7 @@ func TestMultiByteCapability_Unknown(t *testing.T) { // Advert with 1-byte hash only addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1)) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -194,7 +194,7 @@ func TestMultiByteCapability_PrefixCollision(t *testing.T) { } addTestPacket(store, pkt) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 2 { t.Fatalf("expected 2 entries, got %d", len(caps)) } @@ -237,7 +237,7 @@ func TestMultiByteCapability_TraceExcluded(t *testing.T) { } addTestPacket(store, pkt) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -269,7 +269,7 @@ func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) { } addTestPacket(store, pkt) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -304,7 +304,7 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) { } addTestPacket(store, pkt) - caps := store.computeMultiByteCapability() + caps := store.computeMultiByteCapability(nil) if len(caps) != 1 { t.Fatalf("expected 1 entry, got %d", len(caps)) } @@ -312,3 +312,117 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) { t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status) } } + +// TestMultiByteCapability_CompanionConfirmed tests that a companion with +// multi-byte advert is classified as "confirmed", not "unknown" (Bug 1, #754). +func TestMultiByteCapability_CompanionConfirmed(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + "aabbccdd11223344", "CompA", "companion", "2026-04-11T00:00:00Z") + + store := NewPacketStore(db, nil) + addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2)) + + caps := store.computeMultiByteCapability(nil) + if len(caps) != 1 { + t.Fatalf("expected 1 entry, got %d", len(caps)) + } + if caps[0].Status != "confirmed" { + t.Errorf("expected confirmed for companion, got %s", caps[0].Status) + } + if caps[0].Role != "companion" { + t.Errorf("expected role companion, got %s", caps[0].Role) + } + if caps[0].Evidence != "advert" { + t.Errorf("expected advert evidence, got %s", caps[0].Evidence) + } +} + +// TestMultiByteCapability_RoleColumnPopulated tests that the Role field is +// populated for all node types (Bug 2, #754). +func TestMultiByteCapability_RoleColumnPopulated(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + "aabb000000000001", "Rep1", "repeater", "2026-04-11T00:00:00Z") + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + "ccdd000000000002", "Comp1", "companion", "2026-04-11T00:00:00Z") + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + "eeff000000000003", "Room1", "room_server", "2026-04-11T00:00:00Z") + + store := NewPacketStore(db, nil) + addTestPacket(store, makeTestAdvert("aabb000000000001", 2)) + addTestPacket(store, makeTestAdvert("ccdd000000000002", 2)) + addTestPacket(store, makeTestAdvert("eeff000000000003", 1)) + + caps := store.computeMultiByteCapability(nil) + if len(caps) != 3 { + t.Fatalf("expected 3 entries, got %d", len(caps)) + } + + roleByName := map[string]string{} + for _, c := range caps { + roleByName[c.Name] = c.Role + } + if roleByName["Rep1"] != "repeater" { + t.Errorf("Rep1 role: expected repeater, got %s", roleByName["Rep1"]) + } + if roleByName["Comp1"] != "companion" { + t.Errorf("Comp1 role: expected companion, got %s", roleByName["Comp1"]) + } + if roleByName["Room1"] != "room_server" { + t.Errorf("Room1 role: expected room_server, got %s", roleByName["Room1"]) + } +} + +// TestMultiByteCapability_AdopterEvidenceTakesPrecedence tests that when +// adopter data shows hashSize >= 2 but path evidence says "suspected", +// the node is upgraded to "confirmed" (Bug 3, #754). +func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + "aabbccdd11223344", "RepAdopter", "repeater", "2026-04-11T00:00:00Z") + + store := NewPacketStore(db, nil) + + // Only a path-based packet (no advert) — would normally be "suspected" + pathByte := buildPathByte(2, 1) + rawHex := "01" + pathByte + "aabb" + pt := 1 + pkt := &StoreTx{ + RawHex: rawHex, + PayloadType: &pt, + PathJSON: `["aabb"]`, + FirstSeen: "2026-04-10T00:00:00.000Z", + } + addTestPacket(store, pkt) + + // Without adopter data: should be suspected + caps := store.computeMultiByteCapability(nil) + capByName := map[string]MultiByteCapEntry{} + for _, c := range caps { + capByName[c.Name] = c + } + if capByName["RepAdopter"].Status != "suspected" { + t.Errorf("without adopter data: expected suspected, got %s", capByName["RepAdopter"].Status) + } + + // With adopter data showing hashSize 2: should be confirmed + adopterHS := map[string]int{"aabbccdd11223344": 2} + caps = store.computeMultiByteCapability(adopterHS) + capByName = map[string]MultiByteCapEntry{} + for _, c := range caps { + capByName[c.Name] = c + } + if capByName["RepAdopter"].Status != "confirmed" { + t.Errorf("with adopter data: expected confirmed, got %s", capByName["RepAdopter"].Status) + } + if capByName["RepAdopter"].Evidence != "advert" { + t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence) + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index d32d0ef6..7a33981d 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5105,7 +5105,18 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{ // Add multi-byte capability data (only for unfiltered/global view) if region == "" { - result["multiByteCapability"] = s.computeMultiByteCapability() + // Pass adopter hash sizes so capability can cross-reference + adopterHS := make(map[string]int) + if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok { + for _, n := range mbNodes { + pk, _ := n["pubkey"].(string) + hs, _ := n["hashSize"].(int) + if pk != "" && hs >= 2 { + adopterHS[pk] = hs + } + } + } + result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS) } s.cacheMu.Lock() @@ -5818,7 +5829,7 @@ func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo) // --- Multi-Byte Capability Inference --- -// MultiByteCapEntry represents a repeater's inferred multi-byte capability. +// MultiByteCapEntry represents a node's inferred multi-byte capability. type MultiByteCapEntry struct { PublicKey string `json:"pubkey"` Name string `json:"name"` @@ -5830,7 +5841,7 @@ type MultiByteCapEntry struct { } // computeMultiByteCapability determines multi-byte capability for each -// repeater using two methods: +// node (repeaters, companions, rooms, sensors) using two methods: // // 1. Confirmed: the node has advertised with hash_size >= 2 (from advert // path byte). This is 100% reliable because the full public key is @@ -5847,7 +5858,7 @@ type MultiByteCapEntry struct { // with default (1-byte) settings. // // Caller must hold NO locks — this method acquires mu.RLock internally. -func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry { +func (s *PacketStore) computeMultiByteCapability(adopterHashSizes map[string]int) []MultiByteCapEntry { // Get hash size info from adverts (has its own locking) hashInfo := s.GetNodeHashSizeInfo() @@ -5882,24 +5893,21 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry { pubkey string prefix string } - repeaterPrefixes := make(map[string][]prefixEntry) // prefix → entries - for pk, n := range nodeByPK { - if !strings.Contains(strings.ToLower(n.Role), "repeater") { - continue - } + nodePrefixes := make(map[string][]prefixEntry) // prefix → entries + for pk := range nodeByPK { // Generate 1-byte, 2-byte, 3-byte prefixes pkLower := strings.ToLower(pk) for byteLen := 1; byteLen <= 3; byteLen++ { hexLen := byteLen * 2 if len(pkLower) >= hexLen { pfx := pkLower[:hexLen] - repeaterPrefixes[pfx] = append(repeaterPrefixes[pfx], prefixEntry{pk, pfx}) + nodePrefixes[pfx] = append(nodePrefixes[pfx], prefixEntry{pk, pfx}) } } } suspected := make(map[string]int) // pubkey → max hash size from path appearances - for pfx, entries := range repeaterPrefixes { + for pfx, entries := range nodePrefixes { txList := s.byPathHop[pfx] for _, tx := range txList { if tx.RawHex == "" || len(tx.RawHex) < 4 { @@ -5945,9 +5953,9 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry { } s.mu.RUnlock() - // Build result for all repeaters — fetch last_seen from DB + // Build result for all nodes — fetch last_seen from DB dbLastSeen := make(map[string]string) - rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes WHERE role LIKE '%repeater%'") + rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes") if err == nil { defer rows.Close() for rows.Next() { @@ -5962,9 +5970,6 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry { var result []MultiByteCapEntry for pk, n := range nodeByPK { - if !strings.Contains(strings.ToLower(n.Role), "repeater") { - continue - } entry := MultiByteCapEntry{ PublicKey: pk, Name: n.Name, @@ -5977,6 +5982,12 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry { entry.Status = "confirmed" entry.Evidence = "advert" entry.MaxHashSize = maxHS + } else if maxHS, ok := adopterHashSizes[pk]; ok && maxHS >= 2 { + // Adopter data (from computeAnalyticsHashSizes) shows hash_size >= 2 + // from advert analysis — this is advert-based evidence, so confirmed. + entry.Status = "confirmed" + entry.Evidence = "advert" + entry.MaxHashSize = maxHS } else if maxHS, ok := suspected[pk]; ok { entry.Status = "suspected" entry.Evidence = "path" diff --git a/public/analytics.js b/public/analytics.js index d40a4c4b..3fd19b3b 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1000,6 +1000,7 @@ return (filtered.length ? '' + '' + '' + + '' + '' + '' + '' + @@ -1007,8 +1008,10 @@ '' + '' + filtered.map(function(r) { + var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280'; return '' + '' + + '' + '' + '' +
NodeRoleStatusHash SizeAdverts
' + esc(r.name) + '' + esc(r.role || 'unknown') + '' + (statusIcon[r.status] || '❓') + ' ' + (statusLabel[r.status] || 'Unknown') + '' + r.hashSize + '-byte