diff --git a/Dockerfile b/Dockerfile index 30ad45353..d2a0a03f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ COPY internal/mbcapqueue/ ../../internal/mbcapqueue/ COPY internal/lora/ ../../internal/lora/ COPY internal/regions/ ../../internal/regions/ COPY internal/channelregistry/ ../../internal/channelregistry/ +COPY internal/infrastructure/ ../../internal/infrastructure/ RUN go mod download COPY cmd/server/ ./ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ @@ -46,6 +47,7 @@ COPY internal/brokerurl/ ../../internal/brokerurl/ COPY internal/mbcapqueue/ ../../internal/mbcapqueue/ COPY internal/regions/ ../../internal/regions/ COPY internal/channelregistry/ ../../internal/channelregistry/ +COPY internal/infrastructure/ ../../internal/infrastructure/ RUN go mod download COPY cmd/ingestor/ ./ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ diff --git a/cmd/ingestor/go.mod b/cmd/ingestor/go.mod index 16cdd556f..c766508e1 100644 --- a/cmd/ingestor/go.mod +++ b/cmd/ingestor/go.mod @@ -60,6 +60,10 @@ require github.com/meshcore-analyzer/channelregistry v0.0.0 replace github.com/meshcore-analyzer/channelregistry => ../../internal/channelregistry +require github.com/meshcore-analyzer/infrastructure v0.0.0 + +replace github.com/meshcore-analyzer/infrastructure => ../../internal/infrastructure + require github.com/meshcore-analyzer/brokerurl v0.0.0 replace github.com/meshcore-analyzer/brokerurl => ../../internal/brokerurl diff --git a/cmd/ingestor/infrastructure.go b/cmd/ingestor/infrastructure.go new file mode 100644 index 000000000..18a822c78 --- /dev/null +++ b/cmd/ingestor/infrastructure.go @@ -0,0 +1,79 @@ +package main + +import ( + "errors" + "fmt" + "log" + "time" + + "github.com/meshcore-analyzer/channelregistry" + "github.com/meshcore-analyzer/infrastructure" +) + +// runInfrastructureQueueOnce is called by the ingestor only. The server never +// writes the selected snapshot; it writes atomic queue commands only. +func runInfrastructureQueueOnce(store *Store) error { + q := channelregistry.NewQueue(infrastructure.QueuePath(store.path)) + commands, err := q.Pending() + if err != nil { + return err + } + state, err := infrastructure.LoadDB(store.db) + if err != nil { + return err + } + now := time.Now().UnixMilli() + for _, queued := range commands { + if queued.Err != nil { + log.Printf("[infrastructure] unreadable command: %v", queued.Err) + if err := q.Discard(queued.Path); err != nil { + return err + } + continue + } + cmd := queued.Command + res := channelregistry.Result{RequestID: cmd.RequestID, CompletedAt: now} + op, key := "", "" + switch cmd.Op { + case channelregistry.OpSubmit: + op, key = infrastructure.OpSelect, cmd.Name + case channelregistry.OpRevoke: + op, key = infrastructure.OpRemove, cmd.ProposalID + } + if cmd.CreatedAt <= 0 || now-cmd.CreatedAt > 5*60*1000 || cmd.CreatedAt > now+60*1000 { + res.Status, res.Error = channelregistry.RequestError, "request expired" + } else if op == "" { + res.Status, res.Error = channelregistry.RequestError, "invalid operation" + } else if op == infrastructure.OpSelect && !knownInfrastructureRepeater(store, key) { + res.Status, res.Error = channelregistry.RequestError, "node is not a known repeater" + } else if err := state.Apply(cmd.RequestID, op, key, now); err != nil { + res.Status, res.Error = channelregistry.RequestError, err.Error() + } else { + // The SQLite row is the durable idempotence record as well as + // the selected set. Commit it before writing the result file. + if err := infrastructure.SaveDB(store.db, state); err != nil { + return fmt.Errorf("save infrastructure state: %w", err) + } + if op == infrastructure.OpSelect { + res.Status = channelregistry.RequestApproved + } else { + res.Status = channelregistry.RequestRevoked + } + } + if err := q.Complete(res); err != nil { + return fmt.Errorf("complete infrastructure request: %w", err) + } + } + if _, err := q.Prune(time.Now(), 10*time.Minute, infrastructure.MaxRecent); err != nil && !errors.Is(err, channelregistry.ErrUnknownRequest) { + return err + } + return nil +} + +func knownInfrastructureRepeater(store *Store, key string) bool { + known, err := infrastructure.KnownRepeater(store.db, key) + if err != nil { + log.Printf("[infrastructure] repeater lookup failed: %v", err) + } + return err == nil && known +} diff --git a/cmd/ingestor/infrastructure_test.go b/cmd/ingestor/infrastructure_test.go new file mode 100644 index 000000000..45b4e1def --- /dev/null +++ b/cmd/ingestor/infrastructure_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "path/filepath" + "testing" + "time" + + "github.com/meshcore-analyzer/channelregistry" + "github.com/meshcore-analyzer/infrastructure" +) + +func TestInfrastructureQueueRoundTripAndReplay(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "mesh.db") + store, err := OpenStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + q := channelregistry.NewQueue(infrastructure.QueuePath(dbPath)) + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, err := store.db.Exec(`INSERT INTO nodes(public_key, role) VALUES(?, 'repeater')`, key); err != nil { + t.Fatal(err) + } + phantom := channelregistry.Command{RequestID: channelregistry.NewID(), Op: channelregistry.OpSubmit, Name: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", CreatedAt: time.Now().UnixMilli()} + if err := q.Enqueue(phantom, 256); err != nil { + t.Fatal(err) + } + if err := runInfrastructureQueueOnce(store); err != nil { + t.Fatal(err) + } + bad, err := q.Lookup(phantom.RequestID) + if err != nil || bad.Status != channelregistry.RequestError { + t.Fatalf("phantom accepted: %+v, %v", bad, err) + } + a := channelregistry.Command{RequestID: channelregistry.NewID(), Op: channelregistry.OpSubmit, Name: key, CreatedAt: time.Now().UnixMilli()} + if err := q.Enqueue(a, 256); err != nil { + t.Fatal(err) + } + if err := runInfrastructureQueueOnce(store); err != nil { + t.Fatal(err) + } + s, err := infrastructure.LoadDB(store.db) + if err != nil || len(s.Selected) != 1 { + t.Fatalf("selected = %+v, %v", s.Selected, err) + } + b := channelregistry.Command{RequestID: channelregistry.NewID(), Op: channelregistry.OpRevoke, ProposalID: key, CreatedAt: time.Now().UnixMilli()} + if err := q.Enqueue(b, 256); err != nil { + t.Fatal(err) + } + if err := runInfrastructureQueueOnce(store); err != nil { + t.Fatal(err) + } + // A stale, already-applied command may reappear after a crash. Its durable + // request ID must not undo the later remove. + if err := q.Enqueue(a, 256); err != nil { + t.Fatal(err) + } + if err := runInfrastructureQueueOnce(store); err != nil { + t.Fatal(err) + } + s, err = infrastructure.LoadDB(store.db) + if err != nil || len(s.Selected) != 0 { + t.Fatalf("replayed old select: %+v, %v", s, err) + } +} + +func TestInfrastructureSelectionSurvivesDatabaseRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "mesh.db") + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + key := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + if err := infrastructure.SaveDB(store.db, infrastructure.State{Selected: []infrastructure.Entry{{PublicKey: key, AddedAt: 42}}}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + state, err := infrastructure.LoadDB(reopened.db) + if err != nil || len(state.Selected) != 1 || state.Selected[0].PublicKey != key { + t.Fatalf("restart lost curation: %+v, %v", state, err) + } +} diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 56c20baf6..069fb206b 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -445,11 +445,17 @@ func main() { // SQLITE_BUSY. store.RunPendingPruneRequests() store.RunPendingClientRxDeletes() + if err := runInfrastructureQueueOnce(store); err != nil { + log.Printf("[infrastructure] startup queue: %v", err) + } pruneQueueTicker := time.NewTicker(15 * time.Second) go func() { for range pruneQueueTicker.C { store.RunPendingPruneRequests() store.RunPendingClientRxDeletes() + if err := runInfrastructureQueueOnce(store); err != nil { + log.Printf("[infrastructure] queue: %v", err) + } } }() diff --git a/cmd/server/go.mod b/cmd/server/go.mod index cf2705eb0..a26f1230f 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -65,6 +65,10 @@ require github.com/meshcore-analyzer/channelregistry v0.0.0 replace github.com/meshcore-analyzer/channelregistry => ../../internal/channelregistry +require github.com/meshcore-analyzer/infrastructure v0.0.0 + +replace github.com/meshcore-analyzer/infrastructure => ../../internal/infrastructure + require github.com/meshcore-analyzer/brokerurl v0.0.0 replace github.com/meshcore-analyzer/brokerurl => ../../internal/brokerurl diff --git a/cmd/server/infrastructure.go b/cmd/server/infrastructure.go new file mode 100644 index 000000000..f3ea33b86 --- /dev/null +++ b/cmd/server/infrastructure.go @@ -0,0 +1,105 @@ +package main + +import ( + "errors" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/channelregistry" + "github.com/meshcore-analyzer/infrastructure" +) + +// InfrastructureResponse deliberately contains only curation, not a second +// copy of /api/nodes. The browser joins this bounded set to its bulk node +// snapshot and calculates candidate suggestions from existing metrics. +type InfrastructureResponse struct { + Selected []infrastructure.Entry `json:"selected"` +} + +type InfrastructureAuthResponse struct { + OK bool `json:"ok"` +} + +func (s *Server) handleInfrastructureAuth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, InfrastructureAuthResponse{OK: true}) +} + +func (s *Server) infrastructureQueue() *channelregistry.Queue { + // Reuse the existing atomic, bounded file transport in a separate + // directory: OpSubmit maps to select and OpRevoke to remove. No channel + // proposal consumer ever sees these command files. + return channelregistry.NewQueue(infrastructure.QueuePath(s.db.path)) +} + +func (s *Server) handleInfrastructure(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.path == "" { + writeError(w, http.StatusServiceUnavailable, "database unavailable") + return + } + state, err := infrastructure.LoadDB(s.db.conn) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "infrastructure snapshot unavailable") + return + } + if state.Selected == nil { + state.Selected = []infrastructure.Entry{} + } + writeJSON(w, InfrastructureResponse{Selected: state.Selected}) +} + +func (s *Server) handleInfrastructureDecision(op string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.path == "" { + writeError(w, http.StatusServiceUnavailable, "database unavailable") + return + } + key := mux.Vars(r)["pubkey"] + if !infrastructure.ValidPublicKey(key) { + writeError(w, http.StatusBadRequest, "invalid public key") + return + } + if op == infrastructure.OpSelect { + known, err := infrastructure.KnownRepeater(s.db.conn, key) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "node lookup unavailable") + return + } + if !known { + writeError(w, http.StatusConflict, "node is not a known repeater") + return + } + } + id := channelregistry.NewID() + cmd := channelregistry.Command{RequestID: id, CreatedAt: time.Now().UnixMilli()} + if op == infrastructure.OpSelect { + cmd.Op, cmd.Name = channelregistry.OpSubmit, key + } else { + cmd.Op, cmd.ProposalID = channelregistry.OpRevoke, key + } + if err := s.infrastructureQueue().Enqueue(cmd, 256); err != nil { + if errors.Is(err, channelregistry.ErrQueueFull) { + writeError(w, http.StatusTooManyRequests, "infrastructure queue full") + return + } + writeError(w, http.StatusServiceUnavailable, "infrastructure queue unavailable") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + writeJSON(w, ChannelProposalAcceptedResponse{RequestID: id}) + } +} + +func (s *Server) handleInfrastructureRequest(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.path == "" { + writeError(w, http.StatusServiceUnavailable, "database unavailable") + return + } + status, err := s.infrastructureQueue().Lookup(mux.Vars(r)["id"]) + if err != nil { + writeError(w, http.StatusNotFound, "unknown or expired request") + return + } + writeJSON(w, status) +} diff --git a/cmd/server/infrastructure_test.go b/cmd/server/infrastructure_test.go new file mode 100644 index 000000000..00d63f473 --- /dev/null +++ b/cmd/server/infrastructure_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/meshcore-analyzer/channelregistry" + "github.com/meshcore-analyzer/infrastructure" +) + +func TestInfrastructureRoutesReadOnlyAndAuthenticated(t *testing.T) { + srv, router := setupTestServerWithAPIKey(t, "strong-test-api-key-for-infrastructure") + // The normal route fixture predates this optional feature table. + if _, err := srv.db.conn.Exec(`CREATE TABLE infrastructure_state (id INTEGER PRIMARY KEY CHECK(id=1), state_json TEXT NOT NULL)`); err != nil { + t.Fatal(err) + } + srv.db.path = t.TempDir() + "/mesh.db" + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, err := srv.db.conn.Exec(`INSERT INTO nodes(public_key, role) VALUES(?, 'repeater')`, key); err != nil { + t.Fatal(err) + } + phantomReq := httptest.NewRequest("POST", "/api/admin/infrastructure/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/select", nil) + phantomReq.Header.Set("X-API-Key", "strong-test-api-key-for-infrastructure") + phantomResp := httptest.NewRecorder() + router.ServeHTTP(phantomResp, phantomReq) + if phantomResp.Code != http.StatusConflict { + t.Fatalf("phantom select: %d", phantomResp.Code) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/infrastructure", nil)) + if w.Code != http.StatusOK { + t.Fatalf("empty list: %d %s", w.Code, w.Body.String()) + } + var list InfrastructureResponse + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil || list.Selected == nil || len(list.Selected) != 0 { + t.Fatalf("list = %+v, %v", list, err) + } + w = httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("POST", "/api/admin/infrastructure/"+key+"/select", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("unauthorized write: %d", w.Code) + } + auth := httptest.NewRequest("GET", "/api/admin/infrastructure/auth", nil) + auth.Header.Set("X-API-Key", "wrong-test-key") + authResp := httptest.NewRecorder() + router.ServeHTTP(authResp, auth) + if authResp.Code != http.StatusUnauthorized { + t.Fatalf("wrong key auth: %d", authResp.Code) + } + auth.Header.Set("X-API-Key", "strong-test-api-key-for-infrastructure") + authResp = httptest.NewRecorder() + router.ServeHTTP(authResp, auth) + if authResp.Code != http.StatusOK { + t.Fatalf("valid key auth: %d", authResp.Code) + } + req := httptest.NewRequest("POST", "/api/admin/infrastructure/"+key+"/select", nil) + req.Header.Set("X-API-Key", "strong-test-api-key-for-infrastructure") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("queue: %d %s", w.Code, w.Body.String()) + } + var accepted ChannelProposalAcceptedResponse + if err := json.Unmarshal(w.Body.Bytes(), &accepted); err != nil || !channelregistry.ValidID(accepted.RequestID) { + t.Fatalf("accepted = %+v, %v", accepted, err) + } + queued, err := channelregistry.NewQueue(infrastructure.QueuePath(srv.db.path)).Pending() + if err != nil || len(queued) != 1 || queued[0].Command.Name != key { + t.Fatalf("queue = %+v, %v", queued, err) + } + // The API server enqueues only; no curated snapshot exists until the + // ingestor processes the command. + state, err := infrastructure.LoadDB(srv.db.conn) + if err != nil || len(state.Selected) != 0 { + t.Fatalf("server wrote state: %+v, %v", state, err) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 5525579bd..7739c6454 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -62,6 +62,11 @@ func routeDescriptions() map[string]routeMeta { // "POST /api/admin/prune" removed in #1283 (ingestor owns prune). "GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true}, "GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-.db.", Tag: "admin", Auth: true}, + "GET /api/infrastructure": {Summary: "List curated infrastructure repeaters", Description: "Returns the bounded administrator-selected public keys. Candidate suggestions are computed from the existing bulk /api/nodes metrics and never auto-approved.", Tag: "nodes"}, + "GET /api/admin/infrastructure/auth": {Summary: "Validate Infrastructure admin API key", Description: "Returns {ok:true} for an authorized administrator; used before showing curation controls.", Tag: "admin", Auth: true}, + "POST /api/admin/infrastructure/{pubkey}/select": {Summary: "Select an infrastructure repeater", Description: "Queues an administrator decision for the ingestor; returns 202 {requestId}. The server does not write the curated snapshot.", Tag: "admin", Auth: true}, + "POST /api/admin/infrastructure/{pubkey}/remove": {Summary: "Remove an infrastructure repeater", Description: "Queues an administrator decision for the ingestor; returns 202 {requestId}.", Tag: "admin", Auth: true}, + "GET /api/admin/infrastructure/requests/{id}": {Summary: "Check infrastructure decision status", Tag: "admin", Auth: true}, // Packets "GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets", diff --git a/cmd/server/routes.go b/cmd/server/routes.go index c67e817ba..b8271aac0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -343,6 +343,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.Handle("/api/admin/channel-proposals/{id}/approve", s.requireAPIKey(s.handleAdminChannelProposalDecision(channelregistry.OpApprove))).Methods("POST") r.Handle("/api/admin/channel-proposals/{id}/reject", s.requireAPIKey(s.handleAdminChannelProposalDecision(channelregistry.OpReject))).Methods("POST") r.Handle("/api/admin/channel-proposals/{id}/revoke", s.requireAPIKey(http.HandlerFunc(s.handleAdminChannelProposalRevoke))).Methods("POST") + r.HandleFunc("/api/infrastructure", s.handleInfrastructure).Methods("GET") + r.Handle("/api/admin/infrastructure/auth", s.requireAPIKey(http.HandlerFunc(s.handleInfrastructureAuth))).Methods("GET") + r.Handle("/api/admin/infrastructure/{pubkey}/select", s.requireAPIKey(s.handleInfrastructureDecision("select"))).Methods("POST") + r.Handle("/api/admin/infrastructure/{pubkey}/remove", s.requireAPIKey(s.handleInfrastructureDecision("remove"))).Methods("POST") + r.Handle("/api/admin/infrastructure/requests/{id}", s.requireAPIKey(http.HandlerFunc(s.handleInfrastructureRequest))).Methods("GET") r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET") r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET") r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET") diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go index 759c4fd24..2dc5a24b1 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -123,6 +123,12 @@ func Apply(rw *sql.DB, logf Logger) error { if err := ensureChannelProposalsTable(rw, logf); err != nil { return fmt.Errorf("ensure channel_proposals: %w", err) } + if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS infrastructure_state ( + id INTEGER PRIMARY KEY CHECK(id = 1), + state_json TEXT NOT NULL + )`); err != nil { + return fmt.Errorf("ensure infrastructure_state: %w", err) + } if err := ensureTransmissionsRouteMaskColumn(rw, logf); err != nil { return fmt.Errorf("ensure transmissions.route_mask: %w", err) } @@ -161,9 +167,9 @@ func (e *NotReadyError) Error() string { } // Transient reports whether the ingestor resolves the problem by itself -// while it starts: the only missing items are this PR's start-up migration -// still in progress (route_mask_changes not created yet, and possibly the -// transmissions.route_mask column added just before it in the same Apply), +// while it starts: the only missing items are start-up migrations still in +// progress (route_mask_changes or infrastructure_state not created yet, and +// possibly transmissions.route_mask added just before the former), // and every probe failure is SQLite BUSY or LOCKED. Anything else (another // missing item, a malformed change log, a change log whose migration is // recorded but whose table is gone, the column missing while the table @@ -189,7 +195,7 @@ func (e *NotReadyError) Transient() bool { } for _, m := range e.Missing { switch { - case m == "table:route_mask_changes": + case m == "table:route_mask_changes", m == "table:infrastructure_state": case m == "transmissions.route_mask" && tableUnknown: // Added by the same Apply, right before the table. default: @@ -313,6 +319,7 @@ func assertReady(ro *sql.DB, probed func(item string)) error { } mustTable("neighbor_edges") + mustTable("infrastructure_state") mustCol("observations", "resolved_path") mustCol("observers", "inactive") mustCol("observers", "last_packet_at") diff --git a/internal/infrastructure/go.mod b/internal/infrastructure/go.mod new file mode 100644 index 000000000..cb4c2b14f --- /dev/null +++ b/internal/infrastructure/go.mod @@ -0,0 +1,3 @@ +module github.com/meshcore-analyzer/infrastructure + +go 1.22 diff --git a/internal/infrastructure/state.go b/internal/infrastructure/state.go new file mode 100644 index 000000000..a135ea6b0 --- /dev/null +++ b/internal/infrastructure/state.go @@ -0,0 +1,169 @@ +// Package infrastructure stores the administrator's curated repeater set. +// The ingestor is the sole writer; the API server only reads this snapshot and +// places bounded commands in its sibling queue directory. +package infrastructure + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sort" +) + +const ( + MaxSelected = 128 + MaxRecent = 4096 + OpSelect = "select" + OpRemove = "remove" + QueueDirName = "infrastructure-requests" +) + +type Entry struct { + PublicKey string `json:"publicKey"` + AddedAt int64 `json:"addedAt"` +} + +type Decision struct { + RequestID string `json:"requestId"` + AppliedAt int64 `json:"appliedAt"` +} + +type State struct { + Selected []Entry `json:"selected"` + Recent []Decision `json:"recent,omitempty"` +} + +func QueuePath(dbPath string) string { return filepath.Join(filepath.Dir(dbPath), QueueDirName) } + +// KnownRepeater is checked at both ends of the command queue. Select never +// creates a phantom infrastructure item from an arbitrary 64-hex string. +func KnownRepeater(db *sql.DB, key string) (bool, error) { + if !ValidPublicKey(key) { return false, nil } + var exists int + err := db.QueryRow(`SELECT 1 FROM nodes WHERE public_key = ? AND role = 'repeater' LIMIT 1`, key).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { return false, nil } + return err == nil, err +} + +func ValidPublicKey(key string) bool { + if len(key) != 64 { + return false + } + for i := range key { + c := key[i] + if c < '0' || c > '9' { + if c < 'a' || c > 'f' { + return false + } + } + } + return true +} + +func validID(id string) bool { + if len(id) != 16 { + return false + } + for i := range id { + if (id[i] < '0' || id[i] > '9') && (id[i] < 'a' || id[i] > 'f') { + return false + } + } + return true +} + +func (s *State) Apply(id, op, key string, now int64) error { + if !ValidPublicKey(key) || !validID(id) { + return errors.New("invalid infrastructure command") + } + for _, d := range s.Recent { + if d.RequestID == id { + return nil + } + } + // Reject rather than evict a still-live request ID. Queue expiry is five + // minutes; the oldest ID is retained for ten minutes before reuse. + if len(s.Recent) >= MaxRecent && now-s.Recent[0].AppliedAt < 600000 { + return errors.New("infrastructure decision history full") + } + for len(s.Recent) >= MaxRecent { + s.Recent = s.Recent[1:] + } + idx := -1 + for i, e := range s.Selected { + if e.PublicKey == key { + idx = i + break + } + } + switch op { + case OpSelect: + if idx < 0 { + if len(s.Selected) >= MaxSelected { + return errors.New("infrastructure selection full") + } + s.Selected = append(s.Selected, Entry{PublicKey: key, AddedAt: now}) + sort.Slice(s.Selected, func(i, j int) bool { return s.Selected[i].PublicKey < s.Selected[j].PublicKey }) + } + case OpRemove: + if idx >= 0 { + s.Selected = append(s.Selected[:idx], s.Selected[idx+1:]...) + } + default: + return errors.New("invalid infrastructure operation") + } + s.Recent = append(s.Recent, Decision{RequestID: id, AppliedAt: now}) + return nil +} + +func decodeState(b []byte) (State, error) { + var s State + if len(b) > 512*1024 { + return s, errors.New("infrastructure state oversized") + } + if err := json.Unmarshal(b, &s); err != nil { + return State{}, err + } + if len(s.Selected) > MaxSelected || len(s.Recent) > MaxRecent { + return State{}, errors.New("infrastructure state exceeds limit") + } + seen := make(map[string]bool, len(s.Selected)) + for _, e := range s.Selected { + if !ValidPublicKey(e.PublicKey) || seen[e.PublicKey] { + return State{}, fmt.Errorf("invalid infrastructure selection") + } + seen[e.PublicKey] = true + } + return s, nil +} + +// LoadDB reads the single bounded curation row from the main SQLite DB. This +// keeps selections inside the normal SQLite backup and restore lifecycle. +func LoadDB(db *sql.DB) (State, error) { + var raw string + err := db.QueryRow(`SELECT state_json FROM infrastructure_state WHERE id = 1`).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return State{}, nil + } + if err != nil { + return State{}, err + } + return decodeState([]byte(raw)) +} + +// SaveDB is called only by the ingestor's queue processor. The API server +// has a read-only DB connection and never calls it. +func SaveDB(db *sql.DB, s State) error { + if len(s.Selected) > MaxSelected || len(s.Recent) > MaxRecent { + return errors.New("infrastructure state exceeds limit") + } + b, err := json.Marshal(s) + if err != nil { + return err + } + _, err = db.Exec(`INSERT INTO infrastructure_state(id, state_json) VALUES(1, ?) + ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`, string(b)) + return err +} diff --git a/internal/infrastructure/state_test.go b/internal/infrastructure/state_test.go new file mode 100644 index 000000000..92e7533cb --- /dev/null +++ b/internal/infrastructure/state_test.go @@ -0,0 +1,43 @@ +package infrastructure + +import ( + "encoding/json" + "testing" +) + +func TestStateRoundTripAndBounds(t *testing.T) { + key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if !ValidPublicKey(key) || ValidPublicKey("../../etc/passwd") || ValidPublicKey(key[:62]) { + t.Fatal("public-key validation failed") + } + state := State{Selected: []Entry{{PublicKey: key, AddedAt: 123}}} + raw, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + got, err := decodeState(raw) + if err != nil || len(got.Selected) != 1 || got.Selected[0] != state.Selected[0] { + t.Fatalf("roundtrip = %+v, %v", got, err) + } + tooMany, _ := json.Marshal(State{Selected: append(make([]Entry, MaxSelected), Entry{PublicKey: key})}) + if _, err := decodeState(tooMany); err == nil { + t.Fatal("accepted oversized selection") + } +} + +func TestApplyIdempotenceAndReplay(t *testing.T) { + a := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + s := State{} + if err := s.Apply("1111111111111111", OpSelect, a, 10); err != nil { + t.Fatal(err) + } + if err := s.Apply("2222222222222222", OpRemove, a, 20); err != nil { + t.Fatal(err) + } + if err := s.Apply("1111111111111111", OpSelect, a, 10); err != nil { + t.Fatal(err) + } + if len(s.Selected) != 0 { + t.Fatal("old selection replayed after remove") + } +} diff --git a/public/app.js b/public/app.js index 98102468c..158bc0341 100644 --- a/public/app.js +++ b/public/app.js @@ -1340,6 +1340,7 @@ registerPage('tools-landing', { '

Path Inspector

Resolve prefix paths to candidate full-pubkey routes with confidence scoring.

' + '

Trace Viewer

View detailed packet traces by hash.

' + '

Observer Neighbors

Every observer\'s firmware-reported direct neighbors, network-wide in one searchable list.

' + + '

Infrastructure

Curated repeater map and activity-based candidates for admin review.

' + '' + '' + '
' + @@ -1461,7 +1462,7 @@ function navigate() { // Update nav active state document.querySelectorAll('.nav-link[data-route]').forEach(el => { - el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'observer-neighbors-tool' || basePage === 'new-nodes-tool' || basePage === 'node-changes-tool' || basePage === 'network-digest-tool' || basePage === 'position-gaps-tool' || basePage === 'gps-sanity-tool' || basePage === 'tools-landing'))); + el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'observer-neighbors-tool' || basePage === 'new-nodes-tool' || basePage === 'node-changes-tool' || basePage === 'network-digest-tool' || basePage === 'position-gaps-tool' || basePage === 'gps-sanity-tool' || basePage === 'infrastructure' || basePage === 'tools-landing'))); }); // Update "More" button to show active state if a low-priority page is selected var moreBtn = document.getElementById('navMoreBtn'); diff --git a/public/index.html b/public/index.html index 0d2d3f980..ac0b835e2 100644 --- a/public/index.html +++ b/public/index.html @@ -229,6 +229,7 @@ + diff --git a/public/infrastructure.js b/public/infrastructure.js new file mode 100644 index 000000000..9e3337ee4 --- /dev/null +++ b/public/infrastructure.js @@ -0,0 +1,163 @@ +(function () { + 'use strict'; + + var active = 0; + var map = null; + var adminKey = ''; + + function number(v) { var n = Number(v); return Number.isFinite(n) ? n : 0; } + + // Suggestions are only a ranked shortlist, never automatically selected. + // A recorded relay is required so idle/geographically speculative nodes do + // not appear solely because a structural score happened to be high. + function rankCandidates(nodes, selected) { + return (nodes || []).filter(function (n) { + return n && n.role === 'repeater' && n.public_key && !selected.has(n.public_key) && number(n.relay_count_24h) > 0; + }).sort(function (a, b) { + return number(b.relay_count_24h) - number(a.relay_count_24h) || + number(b.bridge_score) - number(a.bridge_score) || + String(a.public_key).localeCompare(String(b.public_key)); + }).slice(0, 20); + } + + function el(tag, className, content) { + var node = document.createElement(tag); + if (className) node.className = className; + if (content != null) node.textContent = String(content); + return node; + } + + function appendNodeRow(parent, node, selected, onDecision) { + var row = el('div', 'infrastructure-row'); + var detail = el('div', 'infrastructure-row-detail'); + var link = el('a', '', node.name || node.public_key); + link.href = '#/nodes/' + encodeURIComponent(node.public_key); + detail.appendChild(link); + detail.appendChild(el('small', '', 'Relayed 24h: ' + number(node.relay_count_24h) + ' · Bridge: ' + number(node.bridge_score).toFixed(2))); + row.appendChild(detail); + var btn = el('button', 'btn btn-secondary', selected ? 'Remove' : 'Select'); + btn.type = 'button'; + btn.hidden = !adminKey; + btn.addEventListener('click', function () { onDecision(node.public_key, selected ? 'remove' : 'select'); }); + row.appendChild(btn); + parent.appendChild(row); + } + + function renderMap(container, nodes) { + if (map) { map.remove(); map = null; } + if (!window.L || !nodes.length) { container.hidden = true; return; } + var points = nodes.filter(function (n) { return n.lat != null && n.lon != null && Number.isFinite(Number(n.lat)) && Number.isFinite(Number(n.lon)) && Math.abs(Number(n.lat)) <= 90 && Math.abs(Number(n.lon)) <= 180 && (Number(n.lat) !== 0 || Number(n.lon) !== 0); }); + if (!points.length) { container.hidden = true; return; } + container.hidden = false; + map = L.map(container, {scrollWheelZoom:false}).setView([Number(points[0].lat), Number(points[0].lon)], 8); + var dark = document.documentElement.getAttribute('data-theme') === 'dark'; + var spec = window.MC_getTileSpec && window.MC_getTileSpec(dark ? 'dark' : 'light'); + if (!spec || !spec.url) spec = {url:'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution:'© OpenStreetMap contributors'}; + L.tileLayer(spec.url, {attribution:spec.attribution, maxZoom:19}).addTo(map); + var color = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || 'currentColor'; + var bounds = []; + points.forEach(function (n) { + var coord = [Number(n.lat), Number(n.lon)]; + bounds.push(coord); + var marker = L.circleMarker(coord, {radius:7, color:color, fillColor:color, fillOpacity:0.8}).addTo(map); + var popup = el('a', '', n.name || n.public_key); + popup.href = '#/nodes/' + encodeURIComponent(n.public_key); + marker.bindPopup(popup); + }); + if (bounds.length > 1) map.fitBounds(bounds, {padding:[20,20], maxZoom:12}); + setTimeout(function () { if (map) map.invalidateSize(); }, 0); + } + + async function init(container) { + var run = ++active; + adminKey = ''; + container.replaceChildren(); + var root = el('section', 'infrastructure-page'); + root.appendChild(el('h1', '', 'Infrastructure')); + root.appendChild(el('p', 'text-muted', 'A curated view of important repeaters. Activity-based suggestions are not added automatically.')); + var status = el('p', 'text-muted', 'Loading…'); + var mapBox = el('div', 'infrastructure-map'); + var selectedTitle = el('h2', '', 'Selected repeaters'); + var selectedList = el('div', 'infrastructure-list'); + var candidateTitle = el('h2', '', 'Suggested candidates'); + var candidateList = el('div', 'infrastructure-list'); + var admin = el('details', 'infrastructure-admin'); + admin.appendChild(el('summary', '', 'Admin controls')); + var keyInput = el('input'); + keyInput.type = 'password'; keyInput.placeholder = 'API key (this tab only)'; keyInput.autocomplete = 'off'; + var unlock = el('button', 'btn btn-secondary', 'Enable controls'); unlock.type = 'button'; + unlock.addEventListener('click', async function () { + var candidateKey = keyInput.value; + keyInput.value = ''; + adminKey = ''; + if (!candidateKey) { status.textContent = 'Enter an API key first.'; render(); return; } + try { + var check = await fetch('/api/admin/infrastructure/auth', {headers:{'X-API-Key':candidateKey}}); + if (run !== active) return; + if (!check.ok) { status.textContent = 'API key was not accepted (' + check.status + ').'; render(); return; } + adminKey = candidateKey; + await load(); + } catch (_) { if (run === active) { status.textContent = 'Could not verify API key.'; render(); } } + }); + admin.appendChild(keyInput); admin.appendChild(unlock); + root.appendChild(admin); root.appendChild(status); root.appendChild(mapBox); + root.appendChild(selectedTitle); root.appendChild(selectedList); + root.appendChild(candidateTitle); root.appendChild(el('p', 'text-muted', 'Top 20 repeaters with observed relays in the last 24 hours, ranked by relay count then bridge score. Review before selecting.')); + root.appendChild(candidateList); container.appendChild(root); + + var nodes = []; + var curated = []; + async function decide(key, op) { + if (!adminKey || !window.confirm((op === 'select' ? 'Select' : 'Remove') + ' this repeater?')) return; + try { + var response = await fetch('/api/admin/infrastructure/' + encodeURIComponent(key) + '/' + op, {method:'POST', headers:{'X-API-Key':adminKey}}); + if (!response.ok) { status.textContent = 'Could not queue change (' + response.status + ').'; return; } + var result = await response.json(); + status.textContent = 'Change queued…'; + for (var i = 0; i < 8 && run === active; i++) { + await new Promise(function (resolve) { setTimeout(resolve, 2000); }); + if (run !== active) return; + var check = await fetch('/api/admin/infrastructure/requests/' + encodeURIComponent(result.requestId), {headers:{'X-API-Key':adminKey}}); + if (!check.ok) { status.textContent = 'Could not check change (' + check.status + ').'; return; } + var outcome = await check.json(); + if (outcome.status !== 'queued') { + status.textContent = outcome.status === 'error' ? (outcome.error || 'Change failed') : 'Selection updated.'; + if (outcome.status !== 'error') await load(); + return; + } + } + status.textContent = 'Still queued; reload to check later.'; + } catch (_) { + if (run === active) status.textContent = 'Could not reach the server. No selection was confirmed.'; + } + } + function render() { + selectedList.replaceChildren(); candidateList.replaceChildren(); + var byKey = new Map(nodes.map(function (n) { return [n.public_key, n]; })); + var selected = new Set(curated.map(function (e) { return e.publicKey; })); + var selectedNodes = curated.map(function (e) { return byKey.get(e.publicKey) || {public_key:e.publicKey, name:'Node not in current list'}; }); + selectedNodes.forEach(function (n) { appendNodeRow(selectedList, n, true, decide); }); + if (!selectedNodes.length) selectedList.appendChild(el('p', 'text-muted', 'No repeaters selected yet.')); + rankCandidates(nodes, selected).forEach(function (n) { appendNodeRow(candidateList, n, false, decide); }); + if (!candidateList.children.length) candidateList.appendChild(el('p', 'text-muted', 'No active candidates in the current node snapshot.')); + renderMap(mapBox, selectedNodes); + } + async function load() { + try { + var data = await Promise.all([api('/infrastructure', {ttl:0}), fetchAllNodes('', {ttl:60000, safetyCap:10000})]); + if (run !== active) return; + curated = Array.isArray(data[0].selected) ? data[0].selected : []; + nodes = data[1].nodes || []; + status.textContent = curated.length + ' selected · ' + nodes.length + ' nodes considered' + + (nodes.length >= 10000 ? ' · Node-list safety cap reached; suggestions may be incomplete.' : ''); + render(); + } catch (_) { if (run === active) status.textContent = 'Infrastructure data is unavailable.'; } + } + load(); + } + + function destroy() { ++active; adminKey = ''; if (map) { map.remove(); map = null; } } + + window.InfrastructurePage = {rankCandidates:rankCandidates, init:init, destroy:destroy}; + if (typeof registerPage === 'function') registerPage('infrastructure', window.InfrastructurePage); +})(); diff --git a/public/style.css b/public/style.css index 9fb2ca15d..7dcff42c9 100644 --- a/public/style.css +++ b/public/style.css @@ -5817,3 +5817,17 @@ body.embed #app.app-fixed { .privacy-page ul { padding-left: 1.3em; } .privacy-page li { margin: 6px 0; } .privacy-page a { color: var(--link-color); } +.infrastructure-page { max-width: 1100px; margin: 0 auto; padding: 20px; } +.infrastructure-map { height: 360px; margin: 16px 0; border-radius: 8px; overflow: hidden; background: var(--surface-1); } +.infrastructure-list { display: grid; gap: 8px; } +.infrastructure-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-1); } +.infrastructure-row-detail { display: grid; min-width: 0; } +.infrastructure-row-detail a { overflow-wrap: anywhere; } +.infrastructure-row-detail small { color: var(--text-muted); } +.infrastructure-row button[hidden] { display: none; } +.infrastructure-admin { margin: 12px 0; } +.infrastructure-admin input { margin: 8px; } +@media (max-width: 600px) { + /* Leave room to scroll the final curation action above the fixed bottom nav. */ + .infrastructure-page { padding-bottom: calc(100px + env(safe-area-inset-bottom)); } +} diff --git a/test-all.sh b/test-all.sh index 3cfdc43a7..d1b2e69d2 100755 --- a/test-all.sh +++ b/test-all.sh @@ -150,6 +150,7 @@ run test-new-nodes-tool.js run test-node-changes-tool.js run test-network-digest-tool.js run test-position-gaps-tool.js +run test-infrastructure.js run test-gps-sanity-tool.js run test-estimated-positions-config.js run test-map-scope-filter.js diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 74182fce1..fe96bc0b4 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -402,6 +402,79 @@ async function run() { } }); + await test('Infrastructure page keeps suggestions separate from selected repeaters', async () => { + await page.goto(`${BASE}/#/infrastructure`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.infrastructure-page'); + await page.waitForFunction(() => { + const root = document.querySelector('.infrastructure-page'); + return root && !root.textContent.includes('Loading…'); + }, { timeout: 15000 }); + const headings = await page.$$eval('.infrastructure-page h2', els => els.map(e => e.textContent)); + assert(headings.includes('Selected repeaters') && headings.includes('Suggested candidates'), 'curation and candidates were merged'); + const hidden = await page.$$eval('.infrastructure-row button', els => els.every(e => e.hidden)); + assert(hidden, 'admin controls should stay locked until a key is entered'); + }); + + await test('Infrastructure admin select/remove stays explicit on mobile', async () => { + const a = 'a'.repeat(64), b = 'b'.repeat(64); + let selected = [a], outcome = 'approved', writes = []; + const nodes = [ + { public_key:a, name:'Curated repeater', role:'repeater', relay_count_24h:3, bridge_score:0.1, lat:55, lon:12 }, + { public_key:b, name:'Candidate repeater', role:'repeater', relay_count_24h:30, bridge_score:0.4, lat:56, lon:13 } + ]; + const oldSize = page.viewportSize(); + const dialog = d => d.accept(); + await page.route('**/api/infrastructure', route => route.fulfill({contentType:'application/json', body:JSON.stringify({selected:selected.map(publicKey => ({publicKey, addedAt:1}))})})); + await page.route('**/api/nodes?*', route => route.fulfill({contentType:'application/json', body:JSON.stringify({nodes, total:nodes.length, counts:{}})})); + await page.route('**/api/admin/infrastructure/**', route => { + const url = route.request().url(); + if (url.endsWith('/auth')) { + return route.fulfill({status:route.request().headers()['x-api-key'] === 'test-secret-key' ? 200 : 401, contentType:'application/json', body:'{"ok":true}'}); + } + if (route.request().method() === 'POST') { + writes.push({url, key:route.request().headers()['x-api-key']}); + if (url.endsWith('/select')) { selected = [a,b]; outcome = 'approved'; } + else { selected = [a]; outcome = 'revoked'; } + return route.fulfill({status:202, contentType:'application/json', body:'{"requestId":"1111111111111111"}'}); + } + return route.fulfill({contentType:'application/json', body:JSON.stringify({status:outcome})}); + }); + page.on('dialog', dialog); + try { + await page.setViewportSize({width:390, height:844}); + await page.goto(`${BASE}/#/infrastructure`, {waitUntil:'domcontentloaded'}); + // A hash-only navigation can retain the previous test's in-memory + // /api/nodes cache. Reload so the synthetic route fixture is used. + await page.reload({waitUntil:'domcontentloaded'}); + await page.waitForSelector('.infrastructure-row'); + await page.getByText('Candidate repeater', {exact:true}).waitFor(); + assert(await page.$eval('.infrastructure-row button', e => e.hidden), 'admin control visible before unlock'); + await page.locator('.infrastructure-admin summary').click(); + await page.locator('.infrastructure-admin input').fill('wrong-key'); + await page.locator('.infrastructure-admin button').click(); + assert(await page.$eval('.infrastructure-row button', e => e.hidden), 'wrong key unlocked curation'); + await page.locator('.infrastructure-admin input').fill('test-secret-key'); + await page.locator('.infrastructure-admin button').click(); + await page.waitForFunction(() => Array.from(document.querySelectorAll('.infrastructure-row button')) + .some(b => b.textContent === 'Select' && !b.hidden)); + await page.locator('.infrastructure-list').nth(1).locator('.infrastructure-row') + .filter({hasText:'Candidate repeater'}).getByRole('button', {name:'Select'}).click(); + await page.waitForFunction(() => document.querySelectorAll('.infrastructure-list')[0].querySelectorAll('.infrastructure-row').length === 2, null, {timeout:10000}); + await page.locator('.infrastructure-list').first().locator('.infrastructure-row') + .filter({hasText:'Candidate repeater'}).getByRole('button', {name:'Remove'}).click(); + await page.waitForFunction(() => document.querySelectorAll('.infrastructure-list')[0].querySelectorAll('.infrastructure-row').length === 1, null, {timeout:10000}); + assert(writes.length === 2 && writes.every(w => w.key === 'test-secret-key'), 'explicit authenticated select/remove not sent'); + const width = await page.$eval('.infrastructure-page', e => e.scrollWidth); + assert(width <= 390, 'infrastructure content overflows mobile viewport'); + } finally { + page.off('dialog', dialog); + await page.unroute('**/api/infrastructure'); + await page.unroute('**/api/nodes?*'); + await page.unroute('**/api/admin/infrastructure/**'); + if (oldSize) await page.setViewportSize(oldSize); + } + }); + // --- Group: Map page (tests 3, 9, 10, 13, 16) --- // Test 3: Map page loads with markers diff --git a/test-infrastructure.js b/test-infrastructure.js new file mode 100644 index 000000000..06b45d655 --- /dev/null +++ b/test-infrastructure.js @@ -0,0 +1,18 @@ +const assert = require('assert'); +const fs = require('fs'); +const vm = require('vm'); + +const context = {window:{}, console}; +vm.createContext(context); +vm.runInContext(fs.readFileSync('public/infrastructure.js', 'utf8'), context); +const rank = context.window.InfrastructurePage.rankCandidates; +const key = n => n.repeat(64); +const rows = [ + {public_key:key('a'), role:'repeater', relay_count_24h:12, bridge_score:0.1}, + {public_key:key('b'), role:'repeater', relay_count_24h:50, bridge_score:0.2}, + {public_key:key('c'), role:'room', relay_count_24h:500, bridge_score:0.9}, + {public_key:key('d'), role:'repeater', relay_count_24h:0, bridge_score:0.9} +]; +assert.deepStrictEqual(Array.from(rank(rows, new Set([key('b')])).map(n => n.public_key)), [key('a')]); +assert.strictEqual(rank(rows, new Set()).length, 2); +console.log('infrastructure candidate ranking: ok');