diff --git a/cmd/server/db.go b/cmd/server/db.go index 1a064facd..5199ef5f9 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2190,6 +2190,10 @@ type PacketPathObserver struct { Approx bool `json:"approx,omitempty"` ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` + // Internal scoring evidence only: airport coordinates are a display + // fallback, not proof that an observer's own GPS still exists. Kept out + // of the wire/archive shape so existing packet-path clients are unchanged. + iataFallback bool } // PacketPathBranch is one station's route to a packet: how far it @@ -2740,6 +2744,7 @@ func buildPacketPathResponseFromReduction( if coord, ok := iataCoords[obs.IATA]; ok { lat, lon := coord.Lat, coord.Lon obs.Lat, obs.Lon = &lat, &lon + obs.iataFallback = true } } if obs.Lat == nil && b.observerPubkey != "" { diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 7365f0a8c..efae40a1d 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -195,6 +195,9 @@ func routeDescriptions() map[string]routeMeta { "GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"}, "GET /api/ping-scores": {Summary: "Ping-score highscore board", Description: "Global (not scoped by region/area) records and leaderboards derived from every ping-bot-triggering channel message ever seen: farthest reach, most hops, widest simultaneous spread, fastest full spread, and most airtime-efficient ping, plus which relay nodes and which observers appear most often. Computed from the same GetPacketPath + LoRa-airtime-estimate logic behind /api/packets/{hash}/path and refreshed on a background interval, so it may lag the very latest ping by a few minutes. Fields are omitted (not zero) until at least one qualifying ping has been recorded.", Tag: "packets", Response: schemaRef("PingScoresResponse")}, + "GET /api/ping-scores/{hash}/path": {Summary: "Get a displayed ping record's saved path", Description: "Returns coherent live or archived path evidence for the current record slot. Archived capture time describes saved geometry, not necessarily the transmission time. Old expired observations cannot be reconstructed. Superseded slot/hash pairs return 404; invalid slots return 400. Current identity privacy rules apply to both sources; unavailable and initializing responses omit path.", Tag: "packets", + QueryParams: []paramMeta{{Name: "record", Description: "allTime. or thisWeek.; kind is farthestPing, mostHopsPing, widestSpreadPing, fastestSpreadPing or mostEfficientPing", Type: "string", Required: true}}, + Response: schemaRef("PingScorePathResponse")}, "GET /api/analytics/areas": {Summary: "Per-configured-Area node density, cross-area bridge nodes, and position-fix coverage", Description: "Three breakdowns over the drawn-polygon Areas configured via the meshguide.dk sync, distinct from hashRegion scope adoption (see /api/analytics/scope-stats): (1) density, node count/active-degraded-silent health/role mix per area (multi-membership via AreaKeysForPoint, so a node in a sub-area also counts toward its parent region), (2) bridgeNodes, nodes whose packet-derived neighbor_edges reach into at least one OTHER area (single most-specific area via AreaKeyForPoint), ranked by how many other areas they reach -- distinct from the network-wide, area-unaware bridge_score betweenness centrality, (3) positionGaps, per area how many nodes have a real GPS fix vs. how many were only placeable via the same neighbor-centroid estimate View Path's approx markers use (nearestPositionedNeighbor, geo-sanity-filtered by Config.NeighborMaxEdgeKm so a stray MQTT-bridge observer↔last-hop edge hundreds of km away can't skew the estimate or inflate its spreadKm). estimatedNodes is the flat, network-wide list backing positionGaps' approximated counts, with actual estimated coordinates -- used by the Areas tab's \"View Estimated Nodes\" map view and Tools > Position-Fix Coverage Gaps. Returns an empty response if no Areas are configured. Cached 30s.", Tag: "analytics", Response: schemaRef("AreaAnalyticsResponse")}, "GET /api/analytics/gps-sanity": {Summary: "Nodes whose self-reported GPS disagrees with their own RF neighbors", Description: "The neighbor-centroid technique nearestPositionedNeighbor uses to ESTIMATE a position for a node with no GPS, flipped around to sanity-check a node that DOES report one. For each node with a real (non-zero) GPS fix, takes its strongest neighbor_edges neighbor as an anchor, keeps whichever other positioned neighbors agree with the anchor within GPSSanityClusterTightKm (50km), and -- only if at least GPSSanityMinClusterSize (2) survive that filter -- compares the node's own position against their weighted centroid. Flags it when the distance exceeds GPSSanitySuspectKm (100km). Most nodes are skipped, not evaluated (no neighbor_edges, no positioned neighbor, or too scattered a neighbor set to trust), so evaluated is always well under totalRealGps. v1: doesn't weight by neighbor_edges' hash-prefix ambiguity mode (the confidence indicator public/nodes.js's Neighbors panel shows) since that breakdown only lives in the in-memory NeighborGraph, not the persisted table this reads. Not area-scoped -- works regardless of whether Areas are configured. Cached 30s.", Tag: "analytics", @@ -551,11 +554,17 @@ func componentSchemas() map[string]interface{} { "airtimeRelayCount": map[string]interface{}{"type": "integer", "description": "Distinct relay count behind estimatedAirtimeMs. Present only alongside it."}, }, }, + "PingScorePathResponse": &openAPISchema{Type: "object", Properties: map[string]*openAPISchema{ + "status": {Type: "string", Enum: []string{"live", "archived", "unavailable", "initializing"}}, + "capturedAt": {Type: "string", Description: "UTC archive capture time, present for archived geometry."}, + "reason": {Type: "string", Enum: []string{"raw_data_expired_before_capture", "no_coordinates", "privacy_filtered", "archive_too_large", "record_evidence_unavailable"}}, + "path": openAPIRef("PacketPathResponse"), + }}, "PingScore": map[string]interface{}{ "type": "object", "description": "One ping's computed highscore-relevant stats, derived from the same GetPacketPath + airtime-annotation logic behind /api/packets/{hash}/path.", "properties": map[string]interface{}{ - "hash": str("The winning ping's packet hash -- pass to /api/packets/{hash}/path for the full View Path map."), + "hash": str("The winning ping's hash; use /api/ping-scores/{hash}/path with its record slot for saved View Path evidence."), "sender": str("Display name of whoever sent the ping, when resolvable from the channel message."), "channelHash": str("Which channel the ping was sent on."), "timestamp": str("RFC3339 timestamp the ping was first seen."), diff --git a/cmd/server/ping_score_history.go b/cmd/server/ping_score_history.go index ed9f4288e..61467426f 100644 --- a/cmd/server/ping_score_history.go +++ b/cmd/server/ping_score_history.go @@ -52,7 +52,9 @@ import ( // PingScoreHistoryEntry.PermanentlyUnreconstructable) and the new // ping_score_history_gaps table (see PingScoreHistoryGap) -- both purely // additive, see applyPingScoreHistoryV2. -const pingScoreHistorySchemaVersion = 2 +// v3 adds bounded displayed-record path archives and a nullable distance +// origin fact in this sidecar. It does not alter or scan the main DB. +const pingScoreHistorySchemaVersion = 3 // PingScoreHistoryStore owns the separate, server-only SQLite connection to // ping_scores_history.db. Not safe for concurrent use from multiple @@ -91,12 +93,16 @@ type PingScoreHistoryEntry struct { RelayCount int RelayPubkeysJSON string FirstPubkey string - Unscorable bool - FingerprintCount int64 - FingerprintMaxID int64 - StableSince string - Settled bool - DataPruned bool + // DistanceFirstPubkey is the landmark behind FarthestKm, independent + // of FirstPubkey's actual earliest-hearer credit. Empty means legacy + // metadata: materialization falls back to FirstPubkey. + DistanceFirstPubkey string + Unscorable bool + FingerprintCount int64 + FingerprintMaxID int64 + StableSince string + Settled bool + DataPruned bool // PermanentlyUnreconstructable is true only once a REAL deep-sweep // attempt (not an age estimate) has actually proven, this tx_id had @@ -550,6 +556,7 @@ type pingScoreHistoryMigration struct { var pingScoreHistoryMigrations = []pingScoreHistoryMigration{ {toVersion: 1, apply: applyPingScoreHistoryV1}, {toVersion: 2, apply: applyPingScoreHistoryV2}, + {toVersion: 3, apply: applyPingScoreHistoryV3}, } func (s *PingScoreHistoryStore) migrateFrom(fromVersion int) error { @@ -710,10 +717,10 @@ const pingScoreHistoryUpsertSQL = ` INSERT INTO ping_score_history_entries ( tx_id, hash, sender, channel_hash, timestamp, station_count, deepest_hops, deepest_pubkey, farthest_km, farthest_pubkey, spread_seconds, airtime_ms, - relay_count, relay_pubkeys_json, first_pubkey, unscorable, + relay_count, relay_pubkeys_json, first_pubkey, distance_first_pubkey, unscorable, fingerprint_count, fingerprint_max_id, stable_since, settled, data_pruned, permanently_unreconstructable, last_deep_swept_at, computed_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(tx_id) DO UPDATE SET hash=excluded.hash, sender=excluded.sender, channel_hash=excluded.channel_hash, timestamp=excluded.timestamp, station_count=excluded.station_count, @@ -721,7 +728,7 @@ ON CONFLICT(tx_id) DO UPDATE SET farthest_km=excluded.farthest_km, farthest_pubkey=excluded.farthest_pubkey, spread_seconds=excluded.spread_seconds, airtime_ms=excluded.airtime_ms, relay_count=excluded.relay_count, relay_pubkeys_json=excluded.relay_pubkeys_json, - first_pubkey=excluded.first_pubkey, unscorable=excluded.unscorable, + first_pubkey=excluded.first_pubkey, distance_first_pubkey=excluded.distance_first_pubkey, unscorable=excluded.unscorable, fingerprint_count=excluded.fingerprint_count, fingerprint_max_id=excluded.fingerprint_max_id, stable_since=excluded.stable_since, settled=excluded.settled, data_pruned=excluded.data_pruned, permanently_unreconstructable=excluded.permanently_unreconstructable, @@ -759,8 +766,9 @@ func (s *PingScoreHistoryStore) UpsertDeleteAndIntegrity(upserts []PingScoreHist // delete, and up to three optional metadata writes -- an integrity record, // a gap record, and the one-time history-initialized marker -- all as ONE // SQL transaction: either everything lands, or (on any error) NONE of it -// does -- the deferred Rollback is a no-op after a successful Commit, and -// fires on every error path before this function returns. +// does. Statement failures use deferred Rollback; a final Commit failure +// additionally clears SQLite's still-open transaction on the sole pooled +// connection before this function returns. // // This exists (Phase 4D, extended in the fix-round-2 review of a1c3022d) // because none of these metadata writes are safe to persist as a SEPARATE @@ -784,10 +792,17 @@ func (s *PingScoreHistoryStore) UpsertDeleteAndIntegrity(upserts []PingScoreHist // value is an RFC3339 timestamp to record under the `_meta` key // 'history_initialized_at'. func (s *PingScoreHistoryStore) UpsertDeleteAndMetadata(upserts []PingScoreHistoryEntry, deleteTxIDs []int64, integrity *PingScoreHistoryIntegrity, gap *PingScoreHistoryGap, historyInitializedAt *string) error { + return s.upsertDeleteMetadataAndArchives(upserts, deleteTxIDs, integrity, gap, historyInitializedAt, nil) +} + +// A non-nil archives map replaces the complete bounded displayed-slot set +// in the very same transaction as this cycle's scores. A nil map preserves +// the set (legacy callers, or an unchanged path archive set). +func (s *PingScoreHistoryStore) upsertDeleteMetadataAndArchives(upserts []PingScoreHistoryEntry, deleteTxIDs []int64, integrity *PingScoreHistoryIntegrity, gap *PingScoreHistoryGap, historyInitializedAt *string, archives map[string]PingScorePathArchive) error { if s.readOnly { return fmt.Errorf("ping score history store: read-only (on-disk schema is newer than this code understands)") } - if len(upserts) == 0 && len(deleteTxIDs) == 0 && integrity == nil && gap == nil && historyInitializedAt == nil { + if len(upserts) == 0 && len(deleteTxIDs) == 0 && integrity == nil && gap == nil && historyInitializedAt == nil && archives == nil { return nil } @@ -809,7 +824,7 @@ func (s *PingScoreHistoryStore) UpsertDeleteAndMetadata(upserts []PingScoreHisto e.StationCount, e.DeepestHops, nullableString(e.DeepestPubkey), nullableFloat(e.FarthestKm), nullableString(e.FarthestPubkey), nullableFloat(e.SpreadSeconds), nullableFloat(e.AirtimeMs), e.RelayCount, nullableString(e.RelayPubkeysJSON), - nullableString(e.FirstPubkey), boolToInt(e.Unscorable), + nullableString(e.FirstPubkey), nullableString(e.DistanceFirstPubkey), boolToInt(e.Unscorable), e.FingerprintCount, e.FingerprintMaxID, nullableString(e.StableSince), boolToInt(e.Settled), boolToInt(e.DataPruned), boolToInt(e.PermanentlyUnreconstructable), nullableString(e.LastDeepSweptAt), e.ComputedAt, @@ -892,7 +907,24 @@ func (s *PingScoreHistoryStore) UpsertDeleteAndMetadata(upserts []PingScoreHisto } } + if err := writePingScorePathArchives(tx, archives); err != nil { + return fmt.Errorf("ping score history store: record path archives: %w", err) + } if err := tx.Commit(); err != nil { + // database/sql marks a Tx done before asking SQLite to COMMIT. + // SQLite can leave the transaction open when COMMIT fails (for + // example on a deferred constraint), so tx.Rollback's deferred + // call would then be an ErrTxDone no-op. This single-owner pool + // has exactly one connection: explicitly clear that transaction + // before any later read/write can see its uncommitted candidate. + if _, rollbackErr := s.conn.Exec(`ROLLBACK`); rollbackErr != nil { + // A lost/automatically rolled-back connection may report that + // there is no active transaction. Retiring the pool is safe in + // either case and prevents a genuinely poisoned connection + // from being used for a subsequent publication. + s.conn.Close() + return fmt.Errorf("ping score history store: commit: %w; rollback cleanup: %v", err, rollbackErr) + } return fmt.Errorf("ping score history store: commit: %w", err) } return nil @@ -903,14 +935,27 @@ func (s *PingScoreHistoryStore) UpsertDeleteAndMetadata(upserts []PingScoreHisto // future column from a newer schema version) never changes what this // specific code version reads. func (s *PingScoreHistoryStore) LoadAll() ([]PingScoreHistoryEntry, error) { - rows, err := s.conn.Query(` + distanceOriginColumn := "distance_first_pubkey" + if s.readOnly { + // A future-version file may have the old-compatible score table + // without this optional column. Stay physically read-only and + // retain its legacy origin semantics rather than migrating it. + var present int + if err := s.conn.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('ping_score_history_entries') WHERE name='distance_first_pubkey'`).Scan(&present); err != nil { + return nil, fmt.Errorf("ping score history store: inspect distance origin column: %w", err) + } + if present == 0 { + distanceOriginColumn = "NULL" + } + } + rows, err := s.conn.Query(fmt.Sprintf(` SELECT tx_id, hash, sender, channel_hash, timestamp, station_count, deepest_hops, deepest_pubkey, farthest_km, farthest_pubkey, spread_seconds, airtime_ms, - relay_count, relay_pubkeys_json, first_pubkey, unscorable, + relay_count, relay_pubkeys_json, first_pubkey, %s, unscorable, fingerprint_count, fingerprint_max_id, stable_since, settled, data_pruned, permanently_unreconstructable, last_deep_swept_at, computed_at FROM ping_score_history_entries - ORDER BY tx_id`) + ORDER BY tx_id`, distanceOriginColumn)) if err != nil { return nil, fmt.Errorf("ping score history store: load all: %w", err) } @@ -919,7 +964,7 @@ func (s *PingScoreHistoryStore) LoadAll() ([]PingScoreHistoryEntry, error) { var out []PingScoreHistoryEntry for rows.Next() { var e PingScoreHistoryEntry - var sender, channelHash, deepestPubkey, farthestPubkey, relayPubkeysJSON, firstPubkey sql.NullString + var sender, channelHash, deepestPubkey, farthestPubkey, relayPubkeysJSON, firstPubkey, distanceFirstPubkey sql.NullString var stableSince, lastDeepSweptAt sql.NullString var farthestKm, spreadSeconds, airtimeMs sql.NullFloat64 var relayCount sql.NullInt64 @@ -927,7 +972,7 @@ func (s *PingScoreHistoryStore) LoadAll() ([]PingScoreHistoryEntry, error) { if err := rows.Scan( &e.TxID, &e.Hash, &sender, &channelHash, &e.Timestamp, &e.StationCount, &e.DeepestHops, &deepestPubkey, &farthestKm, &farthestPubkey, &spreadSeconds, &airtimeMs, - &relayCount, &relayPubkeysJSON, &firstPubkey, &unscorable, + &relayCount, &relayPubkeysJSON, &firstPubkey, &distanceFirstPubkey, &unscorable, &e.FingerprintCount, &e.FingerprintMaxID, &stableSince, &settled, &dataPruned, &permanentlyUnreconstructable, &lastDeepSweptAt, &e.ComputedAt, ); err != nil { @@ -939,6 +984,7 @@ func (s *PingScoreHistoryStore) LoadAll() ([]PingScoreHistoryEntry, error) { e.FarthestPubkey = farthestPubkey.String e.RelayPubkeysJSON = relayPubkeysJSON.String e.FirstPubkey = firstPubkey.String + e.DistanceFirstPubkey = distanceFirstPubkey.String e.StableSince = stableSince.String e.LastDeepSweptAt = lastDeepSweptAt.String if farthestKm.Valid { diff --git a/cmd/server/ping_score_history_convert.go b/cmd/server/ping_score_history_convert.go index 06de2f248..f3d390fdd 100644 --- a/cmd/server/ping_score_history_convert.go +++ b/cmd/server/ping_score_history_convert.go @@ -146,6 +146,7 @@ func pingScoreHistoryEntryFromScore( e.RelayCount = score.RelayCount e.RelayPubkeysJSON = marshalRelayPubkeysJSON(score.relayPubkeys) e.FirstPubkey = score.firstPubkey + e.DistanceFirstPubkey = score.distanceFirstPubkey return e } @@ -164,7 +165,8 @@ func pingScoreHistoryEntryFromScore( // for the full rationale): // - score != nil ("a successful computation") replaces every path fact // (StationCount, DeepestHops, DeepestPubkey, FarthestKm, FarthestPubkey, -// SpreadSeconds, RelayPubkeysJSON, FirstPubkey) with this cycle's +// SpreadSeconds, RelayPubkeysJSON, FirstPubkey, DistanceFirstPubkey) +// with this cycle's // values, and clears Unscorable AND PermanentlyUnreconstructable (see // the score != nil branch below for why the latter is a defensive // clear, not an expected-to-fire code path). @@ -218,6 +220,7 @@ func mergePingScoreHistoryEntry( merged.FarthestPubkey = score.FarthestPubkey merged.SpreadSeconds = score.SpreadSeconds merged.FirstPubkey = score.firstPubkey + merged.DistanceFirstPubkey = score.distanceFirstPubkey merged.RelayPubkeysJSON = marshalRelayPubkeysJSON(score.relayPubkeys) airtimeAlreadyLocked := existing.AirtimeMs != nil && *existing.AirtimeMs > 0 @@ -276,20 +279,26 @@ func materializePingScoreFromHistoryEntry(e PingScoreHistoryEntry) (*PingScore, return nil, fmt.Errorf("materialize tx_id=%d: %w", e.TxID, err) } score := &PingScore{ - Hash: e.Hash, - Sender: e.Sender, - ChannelHash: e.ChannelHash, - Timestamp: e.Timestamp, - StationCount: e.StationCount, - DeepestHops: e.DeepestHops, - DeepestPubkey: e.DeepestPubkey, - FarthestKm: e.FarthestKm, - FarthestPubkey: e.FarthestPubkey, - SpreadSeconds: e.SpreadSeconds, - AirtimeMs: e.AirtimeMs, - RelayCount: e.RelayCount, - relayPubkeys: relayPubkeys, - firstPubkey: e.FirstPubkey, + Hash: e.Hash, + Sender: e.Sender, + ChannelHash: e.ChannelHash, + Timestamp: e.Timestamp, + StationCount: e.StationCount, + DeepestHops: e.DeepestHops, + DeepestPubkey: e.DeepestPubkey, + FarthestKm: e.FarthestKm, + FarthestPubkey: e.FarthestPubkey, + SpreadSeconds: e.SpreadSeconds, + AirtimeMs: e.AirtimeMs, + RelayCount: e.RelayCount, + relayPubkeys: relayPubkeys, + firstPubkey: e.FirstPubkey, + distanceFirstPubkey: e.DistanceFirstPubkey, + } + if score.distanceFirstPubkey == "" { + // A v1/v2 history row had only one origin fact, so its stored + // first station was also the origin used for its distance. + score.distanceFirstPubkey = e.FirstPubkey } if e.FarthestKm != nil && e.AirtimeMs != nil && *e.AirtimeMs > 0 { kmPerSec := *e.FarthestKm / (*e.AirtimeMs / 1000.0) diff --git a/cmd/server/ping_score_history_engine.go b/cmd/server/ping_score_history_engine.go index 1c30985c0..fd360c9c7 100644 --- a/cmd/server/ping_score_history_engine.go +++ b/cmd/server/ping_score_history_engine.go @@ -95,11 +95,12 @@ func defaultPingScoreHistoryEngineConfig() pingScoreHistoryEngineConfig { // and PingScoreHistoryStore's own documented contracts (see their doc // comments). Not registered on *Server or driven from main.go yet. type pingScoreHistoryEngine struct { - server *Server - store *PingScoreHistoryStore - index *pingScoreHistoryIndex - now func() time.Time - config pingScoreHistoryEngineConfig + server *Server + store *PingScoreHistoryStore + index *pingScoreHistoryIndex + now func() time.Time + config pingScoreHistoryEngineConfig + pathArchives map[string]PingScorePathArchive } // newPingScoreHistoryEngine constructs an engine against an ALREADY-OPEN @@ -181,13 +182,18 @@ func newPingScoreHistoryEngine(server *Server, store *PingScoreHistoryStore, now if _, _, err := store.HistoryInitializedAt(); err != nil { return nil, fmt.Errorf("ping score history engine: init: load history-initialized marker: %w", err) } + archives, err := store.LoadPathArchives() + if err != nil { + return nil, fmt.Errorf("ping score history engine: init: load path archives: %w", err) + } return &pingScoreHistoryEngine{ - server: server, - store: store, - index: newPingScoreHistoryIndex(entries), - now: now, - config: config, + server: server, + store: store, + index: newPingScoreHistoryIndex(entries), + now: now, + config: config, + pathArchives: archives, }, nil } @@ -462,6 +468,7 @@ func (e *pingScoreHistoryEngine) Cycle() (*PingScoresSnapshot, error) { trigger := triggerByID[txID] existing, _ := candidateIndex.Get(txID) score := e.server.buildPingScoreFromPath(trigger, pathResultFor(trigger.hash)) + score = preservePingDistanceWithoutGPS(existing, score, pathResultFor(trigger.hash)) state := PingScoreHistoryEntryState{ StableSince: nowStr, Settled: false, DataPruned: existing.DataPruned, LastDeepSweptAt: existing.LastDeepSweptAt, @@ -537,6 +544,7 @@ func (e *pingScoreHistoryEngine) Cycle() (*PingScoresSnapshot, error) { trigger := triggerByID[entry.TxID] existing, _ := candidateIndex.Get(entry.TxID) score := e.server.buildPingScoreFromPath(trigger, pathResultFor(trigger.hash)) + score = preservePingDistanceWithoutGPS(existing, score, pathResultFor(trigger.hash)) fp := fingerprintOf(entry.TxID) fingerprintChanged := fp.Count != existing.FingerprintCount || fp.MaxID != existing.FingerprintMaxID @@ -564,6 +572,15 @@ func (e *pingScoreHistoryEngine) Cycle() (*PingScoresSnapshot, error) { if err != nil { return nil, fmt.Errorf("ping score history cycle: build snapshot: %w", err) } + attempted := make(map[string]bool, len(hashesList)) + for _, h := range hashesList { + attempted[strings.ToLower(h)] = true + } + archives, archivesChanged, err := e.pathsForRecords(snapshot, pathResults, attempted, now) + if err != nil { + return nil, fmt.Errorf("ping score history cycle: %w", err) + } + snapshot.pathArchives = archives // Bootstrap-integrity: computed ONLY on the genuine first bootstrap // (isGenuineBootstrap, captured at the very top of this function, @@ -625,7 +642,11 @@ func (e *pingScoreHistoryEngine) Cycle() (*PingScoresSnapshot, error) { // --- 9. UpsertAndDelete (via UpsertDeleteAndMetadata) in one // persistent transaction --- - if err := e.store.UpsertDeleteAndMetadata(upserts, plan.ToDelete, integrity, gap, historyInitializedAt); err != nil { + var archiveWrites map[string]PingScorePathArchive + if archivesChanged { + archiveWrites = archives + } + if err := e.store.upsertDeleteMetadataAndArchives(upserts, plan.ToDelete, integrity, gap, historyInitializedAt, archiveWrites); err != nil { return nil, fmt.Errorf("ping score history cycle: persist: %w", err) } @@ -634,6 +655,7 @@ func (e *pingScoreHistoryEngine) Cycle() (*PingScoresSnapshot, error) { candidateIndex.Delete(txID) } e.index = candidateIndex + e.pathArchives = archives // --- 11. return the candidate snapshot --- return snapshot, nil @@ -698,6 +720,12 @@ func (e *pingScoreHistoryEngine) QuickSnapshot() (*PingScoresSnapshot, error) { if err != nil { return nil, fmt.Errorf("ping score history quick snapshot: %w", err) } + snapshot.pathArchives = make(map[string]PingScorePathArchive) + for key, score := range pingScoreRecordSlots(snapshot) { + if a, ok := e.pathArchives[key]; ok && a.Hash == score.Hash && a.Timestamp == score.Timestamp && pingPathMatchesRecord(key, score, &a.Path) { + snapshot.pathArchives[key] = a + } + } return snapshot, nil } diff --git a/cmd/server/ping_score_history_paths.go b/cmd/server/ping_score_history_paths.go new file mode 100644 index 000000000..d15d43d3c --- /dev/null +++ b/cmd/server/ping_score_history_paths.go @@ -0,0 +1,368 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "reflect" + "strings" + "time" +) + +// These are storage/response safety ceilings, not protocol assumptions. A +// path exceeding any ceiling is not archived, never silently truncated. +const ( + pingScorePathArchiveMaxRecords = 10 + pingScorePathArchiveMaxBytes = 256 * 1024 + pingScorePathArchiveMaxBranches = 128 + pingScorePathArchiveMaxPoints = 4096 +) + +// PingScorePathArchive contains actual path evidence at CapturedAt, not a +// reconstruction from today's node positions. RecordKey distinguishes two +// cards for the same hash whose evidence was captured at different times. +type PingScorePathArchive struct { + RecordKey string + Hash string + Timestamp string + CapturedAt string + Path PacketPathResponse +} + +var pingScoreRecordKinds = []string{"farthestPing", "mostHopsPing", "widestSpreadPing", "fastestSpreadPing", "mostEfficientPing"} + +func pingScoreRecordSlots(snap *PingScoresSnapshot) map[string]*PingScore { + out := make(map[string]*PingScore, pingScorePathArchiveMaxRecords) + if snap == nil { + return out + } + add := func(prefix string, a, b, c, d, e *PingScore) { + for i, p := range []*PingScore{a, b, c, d, e} { + if p != nil { + out[prefix+"."+pingScoreRecordKinds[i]] = p + } + } + } + add("allTime", snap.FarthestPing, snap.MostHopsPing, snap.WidestSpreadPing, snap.FastestSpreadPing, snap.MostEfficientPing) + if w := snap.ThisWeek; w != nil { + add("thisWeek", w.FarthestPing, w.MostHopsPing, w.WidestSpreadPing, w.FastestSpreadPing, w.MostEfficientPing) + } + return out +} + +func validPingScoreRecordKey(key string) bool { + prefix, kind, ok := strings.Cut(key, ".") + if !ok || (prefix != "allTime" && prefix != "thisWeek") { + return false + } + for _, k := range pingScoreRecordKinds { + if kind == k { + return true + } + } + return false +} + +// Preserve distance only when missing evidence explains the downgrade. When +// both of the previously measured endpoints still have authoritative GPS, +// a new shorter distance is a real correction and must remain allowed. +func preservePingDistanceWithoutGPS(existing PingScoreHistoryEntry, score *PingScore, path *PacketPathResponse) *PingScore { + if score == nil || existing.FarthestKm == nil { + return score + } + if score.FarthestKm != nil && *score.FarthestKm >= *existing.FarthestKm { + return score + } + positioned := func(pk string) bool { + if pk == "" || path == nil { + return false + } + valid := func(o *PacketPathObserver) bool { + return o != nil && strings.EqualFold(o.PublicKey, pk) && !o.Approx && !o.iataFallback && o.Lat != nil && o.Lon != nil + } + if path.First != nil && valid(path.First.Observer) { + return true + } + for i := range path.Branches { + if valid(path.Branches[i].Observer) { + return true + } + } + return false + } + distanceOrigin := existing.DistanceFirstPubkey + if distanceOrigin == "" { + distanceOrigin = existing.FirstPubkey // legacy v1/v2 fact + } + if positioned(distanceOrigin) && positioned(existing.FarthestPubkey) { + return score + } + copy := *score + copy.FarthestKm, copy.FarthestPubkey = existing.FarthestKm, existing.FarthestPubkey + // Keep the old distance landmark separate from the actual current + // first hearer. The latter still feeds the observer leaderboard. + copy.distanceFirstPubkey = distanceOrigin + return © +} + +func pingPathMatchesRecord(key string, score *PingScore, path *PacketPathResponse) bool { + if score == nil || path == nil || len(path.Branches) == 0 || !strings.EqualFold(path.Hash, score.Hash) { + return false + } + _, kind, _ := strings.Cut(key, ".") + var distance, spread *float64 + farthestPubkey := "" + maxHops := 0 + for i := range path.Branches { + b := &path.Branches[i] + if b.Hops > maxHops { + maxHops = b.Hops + } + if b.DistanceFromFirstKm != nil && (distance == nil || *b.DistanceFromFirstKm > *distance) { + distance = b.DistanceFromFirstKm + farthestPubkey = "" + if b.Observer != nil { + farthestPubkey = b.Observer.PublicKey + } + } + if b.SecondsAfterFirst != nil && (spread == nil || *b.SecondsAfterFirst > *spread) { + spread = b.SecondsAfterFirst + } + } + equal := func(a, b *float64) bool { return a != nil && b != nil && *a == *b } + distanceMatches := equal(distance, score.FarthestKm) && strings.EqualFold(farthestPubkey, score.FarthestPubkey) + // A retained old distance must not be paired with a route using a new + // origin, even if its numeric distance happens to be exactly equal. + distanceOrigin := score.distanceFirstPubkey + if distanceOrigin == "" { + distanceOrigin = score.firstPubkey + } + if distanceOrigin != "" { + distanceMatches = distanceMatches && path.First != nil && path.First.Observer != nil && strings.EqualFold(path.First.Observer.PublicKey, distanceOrigin) + } + switch kind { + case "farthestPing": + return distanceMatches + case "mostHopsPing": + deepestPubkey := "" + if path.Branches[0].Observer != nil { + deepestPubkey = path.Branches[0].Observer.PublicKey + } + return maxHops == score.DeepestHops && strings.EqualFold(deepestPubkey, score.DeepestPubkey) + case "widestSpreadPing": + return len(path.Branches) == score.StationCount + case "fastestSpreadPing": + return len(path.Branches) >= 2 && equal(spread, score.SpreadSeconds) + case "mostEfficientPing": + return distanceMatches && equal(path.EstimatedAirtimeMs, score.AirtimeMs) + } + return false +} + +func boundedPingPathJSON(path *PacketPathResponse) ([]byte, error) { + if path == nil || len(path.Branches) == 0 || len(path.Branches) > pingScorePathArchiveMaxBranches { + return nil, fmt.Errorf("path branch bound exceeded or no path") + } + points := 0 + stringsBytes := len(path.Hash) + check := func(b PacketPathBranch) bool { + points += len(b.Points) + for _, p := range b.Points { + stringsBytes += len(p.Name) + len(p.PublicKey) + len(p.Role) + } + if b.Observer != nil { + stringsBytes += len(b.Observer.Name) + len(b.Observer.PublicKey) + len(b.Observer.Role) + len(b.Observer.IATA) + } + return points <= pingScorePathArchiveMaxPoints && stringsBytes <= pingScorePathArchiveMaxBytes/6 + } + for _, b := range path.Branches { + if !check(b) { + return nil, fmt.Errorf("path point/string bound exceeded") + } + } + if path.First != nil && !check(*path.First) { + return nil, fmt.Errorf("first path bound exceeded") + } + // Areas are current config projections, not recorded route evidence. + copy := *path + copy.TouchedAreas = nil + b, err := json.Marshal(copy) + if err != nil { + return nil, err + } + if len(b) > pingScorePathArchiveMaxBytes { + return nil, fmt.Errorf("path byte bound exceeded") + } + return b, nil +} + +func newPingScorePathArchive(key string, score *PingScore, path *PacketPathResponse, now time.Time) (PingScorePathArchive, bool) { + if !validPingScoreRecordKey(key) || !pingPathMatchesRecord(key, score, path) { + return PingScorePathArchive{}, false + } + b, err := boundedPingPathJSON(path) + if err != nil { + return PingScorePathArchive{}, false + } + var immutable PacketPathResponse + if err := json.Unmarshal(b, &immutable); err != nil { + return PingScorePathArchive{}, false + } + immutable.Hash = score.Hash // preserve trigger casing, as score identity does + return PingScorePathArchive{RecordKey: key, Hash: score.Hash, Timestamp: score.Timestamp, CapturedAt: now.UTC().Format(time.RFC3339), Path: immutable}, true +} + +func (e *pingScoreHistoryEngine) pathsForRecords(snap *PingScoresSnapshot, paths map[string]*PacketPathResponse, attempted map[string]bool, now time.Time) (map[string]PingScorePathArchive, bool, error) { + slots := pingScoreRecordSlots(snap) + missing, seen := []string{}, map[string]bool{} + for key, score := range slots { + h := strings.ToLower(score.Hash) + old, ok := e.pathArchives[key] + if !attempted[h] && !(ok && old.Hash == score.Hash && old.Timestamp == score.Timestamp && pingPathMatchesRecord(key, score, &old.Path)) && !seen[h] { + missing, seen[h] = append(missing, h), true + } + } + if len(missing) > 0 { + // At most ten distinct displayed hashes, in one existing bulk helper. + extra, err := e.server.db.GetPacketPathsBulk(missing, e.config.MaxEdgeKm) + if err != nil { + return nil, false, fmt.Errorf("record path capture: %w", err) + } + if paths == nil { + paths = make(map[string]*PacketPathResponse) + } + for _, h := range missing { + paths[h] = extra[h] + if paths[h] != nil { + e.server.annotatePacketPathAirtime(paths[h]) + } + } + } + out := make(map[string]PingScorePathArchive, len(slots)) + for key, score := range slots { + old, oldOK := e.pathArchives[key] + oldOK = oldOK && old.Hash == score.Hash && old.Timestamp == score.Timestamp && pingPathMatchesRecord(key, score, &old.Path) + if next, ok := newPingScorePathArchive(key, score, paths[strings.ToLower(score.Hash)], now); ok { + // Identical evidence doesn't create a new capture time or DB write. + if oldOK { + a, _ := boundedPingPathJSON(&old.Path) + b, _ := boundedPingPathJSON(&next.Path) + if string(a) == string(b) { + next = old + } + } + out[key] = next + } else if oldOK { + out[key] = old + } + } + changed := len(out) != len(e.pathArchives) + for k, v := range out { + if old, ok := e.pathArchives[k]; !ok || !reflect.DeepEqual(old, v) { + changed = true + } + } + return out, changed, nil +} + +func applyPingScoreHistoryV3(tx *sql.Tx) error { + // Nullable metadata only: no main-DB scan, rewrite, or index build. + // Legacy NULL rows use their first_pubkey until recomputed. + // Like the older additive migrations, tolerate a pre-existing column + // when a metadata reset causes the migration ladder to run again. + var present int + if err := tx.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('ping_score_history_entries') WHERE name='distance_first_pubkey'`).Scan(&present); err != nil { + return err + } + if present == 0 { + if _, err := tx.Exec(`ALTER TABLE ping_score_history_entries ADD COLUMN distance_first_pubkey TEXT`); err != nil { + return err + } + } + keys := make([]string, 0, pingScorePathArchiveMaxRecords) + for _, prefix := range []string{"allTime", "thisWeek"} { + for _, kind := range pingScoreRecordKinds { + keys = append(keys, "'"+prefix+"."+kind+"'") + } + } + _, err := tx.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS ping_score_path_archives ( + record_key TEXT PRIMARY KEY CHECK (record_key IN (%s)), + hash TEXT NOT NULL, timestamp TEXT NOT NULL, captured_at TEXT NOT NULL, + path_json TEXT NOT NULL CHECK (length(CAST(path_json AS BLOB)) <= %d) + )`, strings.Join(keys, ","), pingScorePathArchiveMaxBytes)) + return err +} + +func (s *PingScoreHistoryStore) LoadPathArchives() (map[string]PingScorePathArchive, error) { + out := make(map[string]PingScorePathArchive) + // Future schemas remain read-only compatible even if they lack this + // optional table; never write/migrate them merely to open the board. + var present int + if err := s.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='ping_score_path_archives'`).Scan(&present); err != nil { + return nil, err + } + if present == 0 && s.readOnly { + return out, nil + } + rows, err := s.conn.Query(`SELECT record_key, hash, timestamp, captured_at, length(CAST(path_json AS BLOB)), CASE WHEN length(CAST(path_json AS BLOB)) <= ? THEN path_json ELSE NULL END FROM ping_score_path_archives ORDER BY record_key LIMIT ?`, pingScorePathArchiveMaxBytes, pingScorePathArchiveMaxRecords+1) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var a PingScorePathArchive + var size int + var data sql.NullString + if err := rows.Scan(&a.RecordKey, &a.Hash, &a.Timestamp, &a.CapturedAt, &size, &data); err != nil { + return nil, err + } + if !validPingScoreRecordKey(a.RecordKey) || !data.Valid || size > pingScorePathArchiveMaxBytes || len(out) >= pingScorePathArchiveMaxRecords { + return nil, fmt.Errorf("invalid/oversized path archive %q", a.RecordKey) + } + if err := json.Unmarshal([]byte(data.String), &a.Path); err != nil { + return nil, err + } + if _, err := boundedPingPathJSON(&a.Path); err != nil { + return nil, err + } + if a.Path.Hash != a.Hash { + return nil, fmt.Errorf("path archive hash mismatch") + } + if _, err := time.Parse(time.RFC3339, a.CapturedAt); err != nil { + return nil, err + } + out[a.RecordKey] = a + } + return out, rows.Err() +} + +// Validate and replace the old slot set inside the same transaction that +// owns this cycle's scores and integrity metadata. Any error rolls back +// the replacement, so the old evidence is never removed on its own. +func writePingScorePathArchives(tx *sql.Tx, archives map[string]PingScorePathArchive) error { + if archives == nil { + return nil + } + if len(archives) > pingScorePathArchiveMaxRecords { + return fmt.Errorf("too many record path archives") + } + if _, err := tx.Exec(`DELETE FROM ping_score_path_archives`); err != nil { + return err + } + for key, a := range archives { + if key != a.RecordKey || !validPingScoreRecordKey(key) || a.Hash != a.Path.Hash { + return fmt.Errorf("invalid record path identity") + } + if _, err := time.Parse(time.RFC3339, a.CapturedAt); err != nil { + return err + } + b, err := boundedPingPathJSON(&a.Path) + if err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO ping_score_path_archives (record_key,hash,timestamp,captured_at,path_json) VALUES (?,?,?,?,?)`, key, a.Hash, a.Timestamp, a.CapturedAt, string(b)); err != nil { + return err + } + } + return nil +} diff --git a/cmd/server/ping_score_history_record_paths_test.go b/cmd/server/ping_score_history_record_paths_test.go new file mode 100644 index 000000000..5ae678776 --- /dev/null +++ b/cmd/server/ping_score_history_record_paths_test.go @@ -0,0 +1,619 @@ +package main + +import ( + "database/sql" + "encoding/json" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// A nonempty recompute is not necessarily complete evidence: the raw packet +// survives while node retention removes the coordinates used by its record. +func TestPingRecordDistanceSurvivesMissingGPS(t *testing.T) { + for _, partial := range []bool{false, true} { + name := "all_coordinates_missing" + if partial { + name = "nearer_station_still_positioned" + } + t.Run(name, func(t *testing.T) { + fx := setupEngineFixture(t, pingScoreHistoryEngineConfig{SettleDebounce: time.Minute, DeepSweepBatchSize: 100}) + id := seedPingTrigger(t, fx.srv, "gpsloss000001", "#test", "sender", "2026-01-15T10:00:00Z") + seedPingObservation(t, fx.srv, id, "pingobsa", 9, `[]`, `[]`, 1736935200) + seedPingObservation(t, fx.srv, id, "pingobsb", 7, `["aa"]`, `["relay"]`, 1736935210) + seedPingObservation(t, fx.srv, id, "pingobsc", 5, `["aa","bb"]`, `["relay","relay2"]`, 1736935220) + settleEntry(t, fx) + before, _ := fx.engine.index.Get(id) + if before.FarthestKm == nil || *before.FarthestKm < 200 { + t.Fatal("fixture lacks real farthest record") + } + sql := `DELETE FROM nodes WHERE public_key IN ('pingobsa','pingobsb','pingobsc')` + if partial { + sql = `DELETE FROM nodes WHERE public_key = 'pingobsb'` + } + if _, err := fx.srv.db.conn.Exec(sql); err != nil { + t.Fatal(err) + } + fx.clock.Advance(time.Minute) + snap, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + after, _ := fx.engine.index.Get(id) + if after.FarthestKm == nil || *after.FarthestKm != *before.FarthestKm || after.FarthestPubkey != before.FarthestPubkey { + t.Errorf("missing GPS downgraded retained distance: before=%v/%s after=%v/%s", before.FarthestKm, before.FarthestPubkey, after.FarthestKm, after.FarthestPubkey) + } + if snap.FarthestPing == nil || *snap.FarthestPing.FarthestKm != *before.FarthestKm { + t.Error("all-time distance record disappeared or shrank") + } + }) + } +} + +// The path API deliberately exposes an airport fallback as ordinary +// coordinates. It is not evidence that the observer's former own GPS +// remains available, so it cannot justify shrinking a historical record. +func TestPingRecordDistanceSurvivesKnownIATAFallback(t *testing.T) { + for _, onlyFarthestMissing := range []bool{false, true} { + name := "both_airport_fallbacks" + if onlyFarthestMissing { + name = "farthest_airport_fallback" + } + t.Run(name, func(t *testing.T) { + fx, _, before := seedRecordArchiveFixture(t) + for _, statement := range []string{ + `UPDATE observers SET iata='AAR' WHERE id='pingobsa'`, + `UPDATE observers SET iata='CPH' WHERE id='pingobsb'`, + } { + if _, err := fx.srv.db.conn.Exec(statement); err != nil { + t.Fatal(err) + } + } + statement := `DELETE FROM nodes WHERE public_key IN ('pingobsa','pingobsb')` + if onlyFarthestMissing { + statement = `DELETE FROM nodes WHERE public_key='pingobsb'` + } + if _, err := fx.srv.db.conn.Exec(statement); err != nil { + t.Fatal(err) + } + path, err := fx.srv.db.GetPacketPath(before.FarthestPing.Hash, EstimateMaxEdgeKm) + if err != nil { + t.Fatal(err) + } + fresh := fx.srv.buildPingScoreFromPath(pingTriggerRow{hash: before.FarthestPing.Hash}, path) + if fresh.FarthestKm == nil || *fresh.FarthestKm >= *before.FarthestPing.FarthestKm { + t.Fatal("fixture lacks a non-nil airport-based downgrade") + } + fx.clock.Advance(time.Minute) + after, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + if after.FarthestPing == nil || *after.FarthestPing.FarthestKm != *before.FarthestPing.FarthestKm { + t.Fatal("airport fallback shrank historical GPS distance") + } + if !reflect.DeepEqual(before.pathArchives["allTime.farthestPing"], after.pathArchives["allTime.farthestPing"]) { + t.Fatal("airport fallback replaced original record evidence") + } + }) + } +} + +func seedRecordArchiveFixture(t *testing.T) (*engineFixture, int64, *PingScoresSnapshot) { + t.Helper() + fx := setupEngineFixture(t, pingScoreHistoryEngineConfig{SettleDebounce: time.Minute, DeepSweepBatchSize: 100, RetentionDuration: 30 * 24 * time.Hour}) + ts := fx.clock.Now().Add(-time.Hour) + id := seedPingTrigger(t, fx.srv, "archiveping001", "#test", "sender", ts.UTC().Format(time.RFC3339)) + seedPingObservation(t, fx.srv, id, "pingobsa", 9, `[]`, `[]`, ts.Unix()) + seedPingObservation(t, fx.srv, id, "pingobsb", 7, `["aa"]`, `["relay"]`, ts.Unix()+10) + seedPingObservation(t, fx.srv, id, "pingobsc", 5, `["aa","bb"]`, `["relay","relay2"]`, ts.Unix()+20) + settleEntry(t, fx) + snap, err := fx.engine.QuickSnapshot() + if err != nil { + t.Fatal(err) + } + if _, ok := snap.pathArchives["allTime.farthestPing"]; !ok { + t.Fatal("farthest archive not captured") + } + return fx, id, snap +} + +func TestPingRecordArchivesSurviveRetentionAndRestart(t *testing.T) { + fx, id, before := seedRecordArchiveFixture(t) + if len(before.pathArchives) > 10 { + t.Fatal("too many archives") + } + old := before.pathArchives["allTime.farthestPing"] + if _, err := fx.srv.db.conn.Exec(`DELETE FROM observations WHERE transmission_id=?`, id); err != nil { + t.Fatal(err) + } + if _, err := fx.srv.db.conn.Exec(`DELETE FROM transmissions WHERE id=?`, id); err != nil { + t.Fatal(err) + } + fx.clock.Advance(40 * 24 * time.Hour) + after, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + if after.FarthestPing == nil || !reflect.DeepEqual(old, after.pathArchives["allTime.farthestPing"]) { + t.Fatal("retention lost or reinterpreted record path") + } + for key := range after.pathArchives { + if strings.HasPrefix(key, "thisWeek.") { + t.Fatal("superseded weekly archive remains") + } + } + fx.reopenEngine(t) + restarted, err := fx.engine.QuickSnapshot() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(old, restarted.pathArchives["allTime.farthestPing"]) { + t.Fatal("restart lost archived positions") + } + live, err := fx.srv.db.GetPacketPath(old.Hash, EstimateMaxEdgeKm) + if err != nil || len(live.Branches) != 0 { + t.Fatal("fixture did not lose live path") + } +} + +func TestPingRecordArchivesKeepIndependentEvidenceForSameHash(t *testing.T) { + fx, id, before := seedRecordArchiveFixture(t) + old := before.pathArchives["allTime.farthestPing"] + if _, err := fx.srv.db.conn.Exec(`DELETE FROM nodes WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + seedPingObservation(t, fx.srv, id, "pingobsc", 5, `["aa","bb","cc"]`, `["relay","relay2","relay3"]`, fx.clock.Now().Unix()) + fx.clock.Advance(time.Minute) + after, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + f := after.pathArchives["allTime.farthestPing"] + h := after.pathArchives["allTime.mostHopsPing"] + if !reflect.DeepEqual(f, old) { + t.Fatal("poorer GPS overwrote farthest record archive") + } + if f.Hash != h.Hash || h.Path.Branches[0].Hops != 3 || h.CapturedAt == f.CapturedAt { + t.Fatal("same-hash records failed to keep their independent evidence") + } +} + +func TestPingRecordArchiveCommitFailureLeavesLastPublishedAndPersistedState(t *testing.T) { + fx, _, before := seedRecordArchiveFixture(t) + fx.srv.pingScores.Store(before) + oldEntries, err := fx.store.LoadAll() + if err != nil { + t.Fatal(err) + } + // A deferred FK causes a real final Commit failure, after archive and + // score INSERTs succeeded. It exercises rollback, not just validation. + for _, statement := range []string{ + `PRAGMA foreign_keys=ON`, + `CREATE TABLE review_archive_parent (id INTEGER PRIMARY KEY)`, + `CREATE TABLE review_archive_commit_guard (id INTEGER REFERENCES review_archive_parent(id) DEFERRABLE INITIALLY DEFERRED)`, + `CREATE TRIGGER review_archive_commit_fail AFTER INSERT ON ping_score_path_archives BEGIN INSERT INTO review_archive_commit_guard VALUES (123); END`, + } { + if _, err := fx.store.conn.Exec(statement); err != nil { + t.Fatal(err) + } + } + if _, err := fx.srv.db.conn.Exec(`UPDATE nodes SET lat=56.01,lon=10.01 WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + fx.clock.Advance(time.Minute) + after, err := fx.engine.Cycle() + if err == nil || after != nil { + t.Fatal("expected cycle failure at commit") + } + persisted, err := fx.store.LoadAll() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(oldEntries, persisted) || fx.srv.pingScores.Load() != before { + t.Fatal("failed commit changed scores") + } + archives, err := fx.store.LoadPathArchives() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(archives, before.pathArchives) || !reflect.DeepEqual(fx.engine.pathArchives, before.pathArchives) { + t.Fatal("failed commit replaced old archive evidence") + } +} + +func TestPingRecordPathArchiveBoundsAndOversizedLoad(t *testing.T) { + fx, _, snap := seedRecordArchiveFixture(t) + a := snap.pathArchives["allTime.farthestPing"] + for _, name := range []string{"branches", "points", "bytes"} { + t.Run(name, func(t *testing.T) { + b, _ := json.Marshal(a.Path) + var path PacketPathResponse + if err := json.Unmarshal(b, &path); err != nil { + t.Fatal(err) + } + switch name { + case "branches": + path.Branches = make([]PacketPathBranch, pingScorePathArchiveMaxBranches+1) + case "points": + path.Branches[0].Points = make([]PacketPathPoint, pingScorePathArchiveMaxPoints+1) + case "bytes": + path.Branches[0].Observer.Name = strings.Repeat("x", pingScorePathArchiveMaxBytes) + } + if _, err := boundedPingPathJSON(&path); err == nil { + t.Fatal("oversized path was accepted") + } + }) + } + if _, err := fx.store.conn.Exec(`PRAGMA ignore_check_constraints=ON`); err != nil { + t.Fatal(err) + } + if _, err := fx.store.conn.Exec(`UPDATE ping_score_path_archives SET path_json=? WHERE record_key='allTime.farthestPing'`, strings.Repeat("x", pingScorePathArchiveMaxBytes+1)); err != nil { + t.Fatal(err) + } + if _, err := fx.store.LoadPathArchives(); err == nil || !strings.Contains(err.Error(), "oversized") { + t.Fatalf("oversized persisted JSON must be rejected before parsing: %v", err) + } +} + +func TestPingRecordPathArchivesDeduplicateExtraBulkFetch(t *testing.T) { + fx := setupEngineFaultFixture(t, pingScoreHistoryEngineConfig{}) + seedFaultTrigger(t, fx, 1, "bulkarchive01") + snap, err := fx.srv.buildPingScoresSnapshotFromHistory([]pingTriggerRow{{txID: 1, hash: "bulkarchive01", firstSeen: "2026-01-15T10:00:00Z"}}, []PingScoreHistoryEntry{{TxID: 1, Hash: "bulkarchive01", Timestamp: "2026-01-15T10:00:00Z", StationCount: 1, DeepestHops: 0, DeepestPubkey: "obsbulkarchive01"}}, fx.clock.Now()) + if err != nil { + t.Fatal(err) + } + resetBulkTestQueryLog() + archives, _, err := fx.engine.pathsForRecords(snap, nil, nil, fx.clock.Now()) + if err != nil { + t.Fatal(err) + } + queries := 0 + for _, q := range bulkTestQueryLog() { + if strings.Contains(q.sql, "FROM observations o") { + queries++ + if q.argCount != 1 { + t.Fatalf("same record hash fetched multiple times: %d args", q.argCount) + } + } + } + if queries != 1 || len(archives) != 2 { + t.Fatalf("want one bulk fetch and two slots, got %d queries/%d archives", queries, len(archives)) + } +} + +func TestPingScoreHistoryV2ToV3PreservesExistingScores(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.db") + conn, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + tx, err := conn.Begin() + if err != nil { + t.Fatal(err) + } + if err := applyPingScoreHistoryV1(tx); err != nil { + t.Fatal(err) + } + if err := applyPingScoreHistoryV2(tx); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO _meta VALUES ('schema_version','2')`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO ping_score_history_entries(tx_id,hash,timestamp,station_count,deepest_hops,first_pubkey,farthest_km,farthest_pubkey,computed_at) VALUES (1,'older-record','2026-01-01T00:00:00Z',20,8,'legacy-first',100,'legacy-farthest','2026-01-01T00:00:00Z')`); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenPingScoreHistoryStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + entries, err := store.LoadAll() + if err != nil || len(entries) != 1 || entries[0].StationCount != 20 { + t.Fatalf("v2 score not preserved: %v %+v", err, entries) + } + score, err := materializePingScoreFromHistoryEntry(entries[0]) + if err != nil || entries[0].DistanceFirstPubkey != "" || score.distanceFirstPubkey != "legacy-first" { + t.Fatal("legacy nullable distance-origin fallback was not preserved") + } + archives, err := store.LoadPathArchives() + if err != nil || len(archives) != 0 { + t.Fatal("migration fabricated old paths") + } + version, err := store.readSchemaVersion() + if err != nil || version != 3 { + t.Fatal("archive schema not installed") + } +} + +func TestPingRecordAlreadyPrunedWithoutArchiveRemainsUnavailable(t *testing.T) { + fx := setupEngineFixture(t, pingScoreHistoryEngineConfig{}) + _, err := fx.srv.db.conn.Exec(`INSERT INTO ping_triggers(tx_id,hash,first_seen) VALUES(900,'prunedrecord01','2026-01-01T00:00:00Z')`) + if err != nil { + t.Fatal(err) + } + e := PingScoreHistoryEntry{TxID: 900, Hash: "prunedrecord01", Timestamp: "2026-01-01T00:00:00Z", StationCount: 20, DeepestHops: 8, ComputedAt: fx.clock.Now().UTC().Format(time.RFC3339)} + if err := fx.store.UpsertAndDelete([]PingScoreHistoryEntry{e}, nil); err != nil { + t.Fatal(err) + } + fx.reopenEngine(t) + snap, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + if snap.MostHopsPing == nil || len(snap.pathArchives) != 0 { + t.Fatal("lost retained score or invented historical route") + } +} + +func TestPingRecordDistanceAllowsGenuineGPSCorrection(t *testing.T) { + fx := setupEngineFixture(t, pingScoreHistoryEngineConfig{SettleDebounce: time.Minute, DeepSweepBatchSize: 100}) + id := seedPingTrigger(t, fx.srv, "gpscorrect001", "#test", "sender", "2026-01-15T10:00:00Z") + seedPingObservation(t, fx.srv, id, "pingobsa", 9, `[]`, `[]`, 1736935200) + seedPingObservation(t, fx.srv, id, "pingobsb", 7, `["aa"]`, `["relay"]`, 1736935210) + settleEntry(t, fx) + before, _ := fx.engine.index.Get(id) + if _, err := fx.srv.db.conn.Exec(`UPDATE nodes SET lat = 56.01, lon = 10.01 WHERE public_key = 'pingobsb'`); err != nil { + t.Fatal(err) + } + fx.clock.Advance(time.Minute) + if _, err := fx.engine.Cycle(); err != nil { + t.Fatal(err) + } + after, _ := fx.engine.index.Get(id) + if after.FarthestKm == nil || *after.FarthestKm >= *before.FarthestKm { + t.Error("a genuine positioned correction was incorrectly frozen") + } +} + +// First-hearer credit is a live observation fact, distinct from the +// origin landmark for an old retained distance. Preserving the latter +// must not pin the observer leaderboard to a station no longer first. +func TestPingRecordMissingGPSKeepsDistanceOriginNotFirstHearer(t *testing.T) { + fx, id, before := seedRecordArchiveFixture(t) + old := before.pathArchives["allTime.farthestPing"] + if _, err := fx.srv.db.conn.Exec(`DELETE FROM nodes WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + if _, err := fx.srv.db.conn.Exec(`INSERT INTO observers (id,name) VALUES ('pingobsnew','EarlierObserver')`); err != nil { + t.Fatal(err) + } + if _, err := fx.srv.db.conn.Exec(`INSERT INTO nodes (public_key,name,lat,lon) VALUES ('pingobsnew','EarlierObserver',56.02,10.02)`); err != nil { + t.Fatal(err) + } + ts, err := time.Parse(time.RFC3339, before.FarthestPing.Timestamp) + if err != nil { + t.Fatal(err) + } + seedPingObservation(t, fx.srv, id, "pingobsnew", 10, `[]`, `[]`, ts.Unix()-20) + fx.clock.Advance(time.Minute) + after, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + check := func(snap *PingScoresSnapshot) { + t.Helper() + entry, _ := fx.engine.index.Get(id) + if entry.FirstPubkey != "pingobsnew" || snap.FarthestPing.firstPubkey != "pingobsnew" { + t.Errorf("old distance origin incorrectly credited as first hearer: entry=%s, score=%s", entry.FirstPubkey, snap.FarthestPing.firstPubkey) + } + if entry.DistanceFirstPubkey != "pingobsa" || snap.FarthestPing.distanceFirstPubkey != "pingobsa" { + t.Errorf("retained distance origin changed: entry=%s, score=%s", entry.DistanceFirstPubkey, snap.FarthestPing.distanceFirstPubkey) + } + if len(snap.ObserverLeaderboard) != 1 || snap.ObserverLeaderboard[0].Pubkey != "pingobsnew" || snap.ObserverLeaderboard[0].Count != 1 { + t.Errorf("first-hearer leaderboard must credit new earlier observation: %+v", snap.ObserverLeaderboard) + } + if *snap.FarthestPing.FarthestKm != *before.FarthestPing.FarthestKm || !reflect.DeepEqual(old, snap.pathArchives["allTime.farthestPing"]) { + t.Error("retained distance lost its original landmark evidence") + } + } + check(after) + // A physical sidecar close/reopen, not merely rebuilding its in-memory + // index, proves the two identities survive persistence independently. + path := fx.store.path + if err := fx.store.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenPingScoreHistoryStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + fx.store = store + fx.reopenEngine(t) + restarted, err := fx.engine.QuickSnapshot() + if err != nil { + t.Fatal(err) + } + check(restarted) +} + +func TestPingRecordPathArchiveIsImmutableAndChecksLandmarks(t *testing.T) { + fx, _, snap := seedRecordArchiveFixture(t) + a := snap.pathArchives["allTime.farthestPing"] + b, _ := json.Marshal(a.Path) + var source PacketPathResponse + if err := json.Unmarshal(b, &source); err != nil { + t.Fatal(err) + } + next, ok := newPingScorePathArchive(a.RecordKey, snap.FarthestPing, &source, fx.clock.Now()) + if !ok { + t.Fatal("valid matching path rejected") + } + source.Branches[0].Observer.Name = "mutated source" + source.First.Observer.PublicKey = "other origin" + if !reflect.DeepEqual(next.Path, a.Path) { + t.Fatal("archive retained a mutable reference to the live response") + } + if _, ok := newPingScorePathArchive(a.RecordKey, snap.FarthestPing, &source, fx.clock.Now()); ok { + t.Fatal("same numeric distance with a different first landmark accepted") + } +} + +func TestPingRecordGenuineCorrectionReplacesArchive(t *testing.T) { + fx, _, before := seedRecordArchiveFixture(t) + old := before.pathArchives["allTime.farthestPing"] + if _, err := fx.srv.db.conn.Exec(`UPDATE nodes SET lat=56.01,lon=10.01 WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + // Do not advance the clock: a real geometry correction within one + // RFC3339 second must still be persisted, not mistaken for unchanged. + after, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + next, ok := after.pathArchives["allTime.farthestPing"] + if !ok || *after.FarthestPing.FarthestKm >= *before.FarthestPing.FarthestKm || reflect.DeepEqual(old.Path, next.Path) { + t.Fatal("genuine coordinate correction did not replace metric and archive") + } + stored, err := fx.store.LoadPathArchives() + if err != nil || !reflect.DeepEqual(next, stored["allTime.farthestPing"]) { + t.Fatalf("same-second corrected archive not persisted: %v", err) + } +} + +func TestPingRecordPathArchiveSlotBoundAndInvalidWriteRollback(t *testing.T) { + fx, _, snap := seedRecordArchiveFixture(t) + old := snap.pathArchives["allTime.farthestPing"] + full := make(map[string]PingScorePathArchive) + for _, prefix := range []string{"allTime", "thisWeek"} { + for _, kind := range pingScoreRecordKinds { + a := old + a.RecordKey = prefix + "." + kind + full[a.RecordKey] = a + } + } + if err := fx.store.upsertDeleteMetadataAndArchives(nil, nil, nil, nil, nil, full); err != nil { + t.Fatal(err) + } + stored, err := fx.store.LoadPathArchives() + if err != nil || len(stored) != pingScorePathArchiveMaxRecords { + t.Fatalf("ten-slot archive failed: %v / %d", err, len(stored)) + } + full["extra"] = old + if err := fx.store.upsertDeleteMetadataAndArchives(nil, nil, nil, nil, nil, full); err == nil { + t.Fatal("eleventh archive accepted") + } + delete(full, "extra") + bad := old + bad.RecordKey = "invalid.slot" + delete(full, "allTime.farthestPing") + full[bad.RecordKey] = bad + if err := fx.store.upsertDeleteMetadataAndArchives(nil, nil, nil, nil, nil, full); err == nil { + t.Fatal("invalid slot accepted") + } + after, err := fx.store.LoadPathArchives() + if err != nil || !reflect.DeepEqual(stored, after) { + t.Fatalf("invalid write removed old committed archives: %v", err) + } +} + +func TestPingRecordFutureSchemaLoadsArchivesReadOnly(t *testing.T) { + fx, _, snap := seedRecordArchiveFixture(t) + if _, err := fx.store.conn.Exec(`UPDATE _meta SET value='999' WHERE key='schema_version'`); err != nil { + t.Fatal(err) + } + path := fx.store.path + if err := fx.store.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenPingScoreHistoryStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + archives, err := store.LoadPathArchives() + if err != nil || !store.ReadOnly() || !reflect.DeepEqual(archives, snap.pathArchives) { + t.Fatalf("future schema archive read failed: %v", err) + } + if err := store.upsertDeleteMetadataAndArchives(nil, nil, nil, nil, nil, map[string]PingScorePathArchive{}); err == nil { + t.Fatal("future schema permitted archive write") + } + if _, err := store.conn.Exec(`DELETE FROM ping_score_path_archives`); err == nil { + t.Fatal("future schema connection is physically writable") + } +} + +func TestPingRecordFutureLegacySchemaUsesReadOnlyDistanceOriginFallback(t *testing.T) { + path := filepath.Join(t.TempDir(), "future-legacy.db") + conn, err := openPingScoreHistoryConn(path, true) + if err != nil { + t.Fatal(err) + } + tx, err := conn.Begin() + if err != nil { + t.Fatal(err) + } + if err := applyPingScoreHistoryV1(tx); err != nil { + t.Fatal(err) + } + if err := applyPingScoreHistoryV2(tx); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO _meta VALUES ('schema_version','999')`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`INSERT INTO ping_score_history_entries(tx_id,hash,timestamp,station_count,deepest_hops,first_pubkey,farthest_km,computed_at) VALUES (1,'future-legacy','2026-01-01T00:00:00Z',2,1,'legacy-first',10,'2026-01-01T00:00:00Z')`); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenPingScoreHistoryStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + entries, err := store.LoadAll() + if err != nil || !store.ReadOnly() || len(entries) != 1 { + t.Fatalf("future legacy score read failed: %v", err) + } + score, err := materializePingScoreFromHistoryEntry(entries[0]) + if err != nil || score.distanceFirstPubkey != "legacy-first" || score.firstPubkey != "legacy-first" { + t.Fatal("future legacy distance origin not materialized") + } + archives, err := store.LoadPathArchives() + if err != nil || len(archives) != 0 { + t.Fatal("future legacy archive absence should be unavailable, not a migration") + } + if _, err := store.conn.Exec(`ALTER TABLE ping_score_history_entries ADD COLUMN distance_first_pubkey TEXT`); err == nil { + t.Fatal("future legacy connection is physically writable") + } +} + +func BenchmarkPingRecordPathArchiveCapture(b *testing.B) { + path := PacketPathResponse{Hash: "bench-ping"} + for i := 0; i < 16; i++ { + branch := PacketPathBranch{Hops: 16, Observer: &PacketPathObserver{PublicKey: strings.Repeat("a", 64), Name: "observer"}} + for j := 0; j < 16; j++ { + branch.Points = append(branch.Points, PacketPathPoint{PublicKey: strings.Repeat("b", 64), Name: "repeater", Role: "repeater"}) + } + path.Branches = append(path.Branches, branch) + } + score := &PingScore{Hash: path.Hash, StationCount: len(path.Branches), Timestamp: "2026-01-15T10:00:00Z"} + now := time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC) + data, err := boundedPingPathJSON(&path) + if err != nil { + b.Fatal(err) + } + b.ReportMetric(float64(len(data)), "archive-bytes") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, ok := newPingScorePathArchive("allTime.widestSpreadPing", score, &path, now); !ok { + b.Fatal("representative bounded archive rejected") + } + } +} diff --git a/cmd/server/ping_score_path.go b/cmd/server/ping_score_path.go new file mode 100644 index 000000000..636682fdb --- /dev/null +++ b/cmd/server/ping_score_path.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// PingScorePathResponse distinguishes recorded evidence from today's live +// geometry and from an old record whose observations expired before capture. +// An unavailable route is not an empty map, nor evidence of zero reception. +type PingScorePathResponse struct { + Status string `json:"status"` + CapturedAt string `json:"capturedAt,omitempty"` + Reason string `json:"reason,omitempty"` + Path *PacketPathResponse `json:"path,omitempty"` +} + +func (s *Server) handlePingScorePath(w http.ResponseWriter, r *http.Request) { + // Paths are bounded to the ten currently displayed record slots, not a + // second general-purpose packet API or an archive of all historical pings. + key := r.URL.Query().Get("record") + if !validPingScoreRecordKey(key) { + writeError(w, http.StatusBadRequest, "invalid ping record slot") + return + } + hash := mux.Vars(r)["hash"] + snap := s.pingScores.Load() + if snap == nil { + writeJSON(w, PingScorePathResponse{Status: "initializing"}) + return + } + score := pingScoreRecordSlots(snap)[key] + if score == nil || !strings.EqualFold(score.Hash, hash) { + writeError(w, http.StatusNotFound, "ping record is no longer in this slot") + return + } + result := PingScorePathResponse{Status: "live"} + var path *PacketPathResponse + // Prefer the evidence captured with this card's metrics. In particular, + // current GPS loss must not turn a retained distance into a smaller map. + if a, ok := snap.pathArchives[key]; ok && a.Hash == score.Hash && a.Timestamp == score.Timestamp { + path = &a.Path + result.Status, result.CapturedAt = "archived", a.CapturedAt + } else if s.db != nil { + var err error + path, err = s.db.GetPacketPath(score.Hash, EstimateMaxEdgeKm) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load ping record path") + return + } + s.annotatePacketPathAirtime(path) + if path != nil && len(path.Branches) > 0 && !pingPathMatchesRecord(key, score, path) { + writeJSON(w, PingScorePathResponse{Status: "unavailable", Reason: "record_evidence_unavailable"}) + return + } + } + if path == nil || len(path.Branches) == 0 { + writeJSON(w, PingScorePathResponse{Status: "unavailable", Reason: "raw_data_expired_before_capture"}) + return + } + data, err := boundedPingPathJSON(path) + if err != nil { + writeJSON(w, PingScorePathResponse{Status: "unavailable", Reason: "archive_too_large"}) + return + } + // Snapshot archives are immutable and shared with the history worker. + // Clone before per-request filtering; never change their cached geometry. + var visible PacketPathResponse + if err := json.Unmarshal(data, &visible); err != nil { + writeError(w, http.StatusInternalServerError, "could not read ping record path") + return + } + filtered, err := s.filterPingScorePath(r.Context(), &visible) + if err != nil { + // A failed live name lookup cannot turn cached private names public. + writeError(w, http.StatusInternalServerError, "could not check ping path visibility") + return + } + if len(visible.Branches) == 0 || !pingScorePathHasCoordinates(&visible) { + reason := "no_coordinates" + if filtered { + reason = "privacy_filtered" + } + writeJSON(w, PingScorePathResponse{Status: "unavailable", Reason: reason}) + return + } + s.annotatePacketPathTouchedAreas(&visible) + result.Path = &visible + writeJSON(w, result) +} + +func pingScorePathHasCoordinates(path *PacketPathResponse) bool { + positioned := func(b PacketPathBranch) bool { + if b.Observer != nil && b.Observer.Lat != nil && b.Observer.Lon != nil { + return true + } + for _, p := range b.Points { + if p.Lat != nil && p.Lon != nil { + return true + } + } + return false + } + for _, b := range path.Branches { + if positioned(b) { + return true + } + } + return path.First != nil && positioned(*path.First) +} + +// filterPingScorePath applies the shared identity policy to recorded names +// AND current node/observer/inactive names. Entire hidden branches disappear: +// removing just a point would draw a fictitious edge across the hidden hop. +func (s *Server) filterPingScorePath(ctx context.Context, path *PacketPathResponse) (bool, error) { + keys := make([]string, 0) + collect := func(b PacketPathBranch) { + for _, p := range b.Points { + keys = append(keys, p.PublicKey) + } + if b.Observer != nil { + keys = append(keys, b.Observer.PublicKey) + } + } + for _, b := range path.Branches { + collect(b) + } + if path.First != nil { + collect(*path.First) + } + var names map[string][]string + var err error + if s.cfg != nil { + names, err = s.hiddenIdentityNames(ctx, keys) + if err != nil { + return false, err + } + } + hidden := func(pk, recorded string) bool { + return identityHidden(s.cfg, pk, recorded) || identityHidden(s.cfg, pk, names[strings.ToLower(pk)]...) + } + branchHidden := func(b PacketPathBranch) bool { + if b.Observer != nil && hidden(b.Observer.PublicKey, b.Observer.Name) { + return true + } + for _, p := range b.Points { + if hidden(p.PublicKey, p.Name) { + return true + } + } + return false + } + filtered := false + kept := path.Branches[:0] + for _, b := range path.Branches { + if branchHidden(b) { + filtered = true + continue + } + kept = append(kept, b) + } + path.Branches = kept + if path.First != nil && branchHidden(*path.First) { + path.First = nil + filtered = true + // These measurements were relative to a now-hidden origin. + for i := range path.Branches { + path.Branches[i].DistanceFromFirstKm = nil + path.Branches[i].SecondsAfterFirst = nil + } + } + // Neighbor-centroid positions have no contributor identities in the path + // contract. With a visibility policy we cannot prove their contributors + // are public, so omit this geometry rather than leak a hidden location. + restricted := s.cfg != nil && (s.cfg.HasNodeBlacklist() || len(s.cfg.ObserverBlacklist) > 0 || len(s.cfg.EnforcedHiddenNamePrefixes()) > 0) + if restricted { + stripApprox := func(b *PacketPathBranch) { + for i := range b.Points { + p := &b.Points[i] + if p.Approx { + p.Lat, p.Lon, p.ApproxSpreadKm = nil, nil, nil + p.ApproxNeighborCount = 0 + filtered = true + } + } + if o := b.Observer; o != nil && o.Approx { + o.Lat, o.Lon, o.ApproxSpreadKm = nil, nil, nil + o.ApproxNeighborCount = 0 + filtered = true + } + } + for i := range path.Branches { + stripApprox(&path.Branches[i]) + } + if path.First != nil { + stripApprox(path.First) + } + } + path.TouchedAreas = nil // recomputed from visible geometry and current config + if filtered { + path.EstimatedAirtimeMs, path.AirtimeRelayCount = nil, 0 + } + return filtered, nil +} diff --git a/cmd/server/ping_score_path_test.go b/cmd/server/ping_score_path_test.go new file mode 100644 index 000000000..b43a3ed0d --- /dev/null +++ b/cmd/server/ping_score_path_test.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" +) + +func TestPingScorePathLiveRecord(t *testing.T) { + srv, router := setupPingScoresFixture(t) + hash := "pingpathlive01" + txID := seedPingTrigger(t, srv, hash, "#test", "Sender", "2026-01-15T10:00:00Z") + seedPingObservation(t, srv, txID, "pingobsa", 9, `[]`, `[]`, 1736935200) + seedPingObservation(t, srv, txID, "pingobsb", 6, `["aa"]`, `["pkrelay1"]`, 1736935210) + srv.pingScores.Store(srv.computeAllPingScores()) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/"+hash+"/path?record=allTime.farthestPing", nil)) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + Status string `json:"status"` + Path *PacketPathResponse `json:"path"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Status != "live" || response.Path == nil || len(response.Path.Branches) != 2 { + t.Fatalf("response=%+v, want live path with two branches", response) + } +} + +func TestPingScorePathMissingOldDataIsExplicit(t *testing.T) { + srv, router := setupPingScoresFixture(t) + srv.pingScores.Store(&PingScoresSnapshot{TotalPings: 1, FarthestPing: &PingScore{ + Hash: "oldrecord", Timestamp: "2026-01-15T10:00:00Z", StationCount: 2, + }}) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/oldrecord/path?record=allTime.farthestPing", nil)) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + Status string `json:"status"` + Reason string `json:"reason"` + Path *PacketPathResponse `json:"path"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Status != "unavailable" || response.Reason != "raw_data_expired_before_capture" || response.Path != nil { + t.Fatalf("response=%+v, want explicit unavailable without an empty map", response) + } +} + +func TestPingScorePathInitializingIsNotNoPings(t *testing.T) { + _, router := setupPingScoresFixture(t) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/oldrecord/path?record=allTime.farthestPing", nil)) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Status != "initializing" { + t.Fatalf("status=%q, want initializing", response.Status) + } +} + +func testPingScorePathArchive(t *testing.T, srv *Server) *PingScoresSnapshot { + t.Helper() + lat, lon, farLat, distance, seconds, airtime := 56.0, 10.0, 57.0, 111.0, 20.0, 50.0 + first := PacketPathBranch{Observer: &PacketPathObserver{PublicKey: "pingobsa", Name: "Saved A", Lat: &lat, Lon: &lon}} + far := PacketPathBranch{Hops: 1, Points: []PacketPathPoint{{PublicKey: "RELAY", Name: "Saved relay", Lat: &lat, Lon: &lon}}, Observer: &PacketPathObserver{PublicKey: "PINGOBSB", Name: "Saved B", Lat: &farLat, Lon: &lon}, DistanceFromFirstKm: &distance, SecondsAfterFirst: &seconds} + path := PacketPathResponse{Hash: "archive01", Branches: []PacketPathBranch{far, first}, First: &first, EstimatedAirtimeMs: &airtime, AirtimeRelayCount: 1, TouchedAreas: []TouchedAreaShape{{Label: "Obsolete area"}}} + score := &PingScore{Hash: path.Hash, Timestamp: "2026-01-15T10:00:00Z", StationCount: 2, FarthestKm: &distance} + snap := &PingScoresSnapshot{TotalPings: 1, FarthestPing: score, pathArchives: map[string]PingScorePathArchive{ + "allTime.farthestPing": {RecordKey: "allTime.farthestPing", Hash: path.Hash, Timestamp: score.Timestamp, CapturedAt: "2026-01-15T10:05:00Z", Path: path}, + }} + srv.pingScores.Store(snap) + return snap +} + +func TestPingScorePathArchivePrivacyAndImmutability(t *testing.T) { + for _, tc := range []struct { + name, excluded string + configure func(*testing.T, *Server, *PingScoresSnapshot) + firstHidden bool + }{ + {"node blacklist case insensitive", "RELAY", func(_ *testing.T, s *Server, _ *PingScoresSnapshot) { s.cfg.SetNodeBlacklist([]string{"relay"}) }, false}, + {"observer blacklist case insensitive", "PINGOBSB", func(_ *testing.T, s *Server, _ *PingScoresSnapshot) { s.cfg.ObserverBlacklist = []string{"pingobsb"} }, false}, + {"saved hidden name", "Saved B", func(_ *testing.T, s *Server, _ *PingScoresSnapshot) { s.cfg.SetHiddenNamePrefixes([]string{"Saved B"}) }, false}, + {"current hidden observer name", "PINGOBSB", func(t *testing.T, s *Server, _ *PingScoresSnapshot) { + s.cfg.SetHiddenNamePrefixes([]string{"Secret"}) + if _, err := s.db.conn.Exec(`UPDATE observers SET name = 'Secret now' WHERE id = 'pingobsb'`); err != nil { + t.Fatal(err) + } + }, false}, + {"current hidden first node", "pingobsa", func(t *testing.T, s *Server, _ *PingScoresSnapshot) { + s.cfg.SetHiddenNamePrefixes([]string{"Secret"}) + if _, err := s.db.conn.Exec(`UPDATE nodes SET name = 'Secret now' WHERE public_key = 'pingobsa'`); err != nil { + t.Fatal(err) + } + }, true}, + {"inactive hidden first node", "pingobsa", func(t *testing.T, s *Server, _ *PingScoresSnapshot) { + s.cfg.SetHiddenNamePrefixes([]string{"Secret"}) + if _, err := s.db.conn.Exec(`CREATE TABLE IF NOT EXISTS inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT); INSERT INTO inactive_nodes(public_key,name) VALUES('pingobsa','Secret retired'); DELETE FROM nodes WHERE public_key='pingobsa'`); err != nil { + t.Fatal(err) + } + }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, router := setupPingScoresFixture(t) + snap := testPingScorePathArchive(t, srv) + tc.configure(t, srv, snap) + before := mustJSON(t, snap.pathArchives) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/archive01/path?record=allTime.farthestPing", nil)) + if rr.Code != 200 { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response PingScorePathResponse + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Status != "archived" || response.CapturedAt != "2026-01-15T10:05:00Z" || response.Path == nil || len(response.Path.Branches) != 1 { + t.Fatalf("unexpected response: %s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), tc.excluded) || strings.Contains(rr.Body.String(), "Obsolete area") { + t.Fatalf("hidden/stale evidence leaked: %s", rr.Body.String()) + } + if response.Path.EstimatedAirtimeMs != nil || response.Path.AirtimeRelayCount != 0 { + t.Fatal("hidden relay aggregate leaked") + } + if tc.firstHidden && (response.Path.First != nil || response.Path.Branches[0].DistanceFromFirstKm != nil || response.Path.Branches[0].SecondsAfterFirst != nil) { + t.Fatal("hidden origin leaked through relative metrics") + } + if got := mustJSON(t, snap.pathArchives); got != before { + t.Fatal("request changed shared path archive") + } + }) + } +} + +func TestPingScorePathVisibilityLookupFailsClosed(t *testing.T) { + srv, router := setupPingScoresFixture(t) + testPingScorePathArchive(t, srv) + srv.cfg.SetHiddenNamePrefixes([]string{"Secret"}) + if _, err := srv.db.conn.Exec(`DROP TABLE observers`); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/archive01/path?record=allTime.farthestPing", nil)) + if rr.Code != 500 || strings.Contains(rr.Body.String(), "Saved") { + t.Fatalf("status=%d body=%s, want closed failure", rr.Code, rr.Body.String()) + } +} + +func TestPingScorePathRejectsOtherSlotsAndStaleLinks(t *testing.T) { + srv, router := setupPingScoresFixture(t) + testPingScorePathArchive(t, srv) + for _, tc := range []struct { + path string + want int + }{ + {"archive01/path", 400}, + {"archive01/path?record=anything.farthestPing", 400}, + {"archive01/path?record=allTime.mostHopsPing", 404}, + {"older/path?record=allTime.farthestPing", 404}, + } { + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/"+tc.path, nil)) + if rr.Code != tc.want || strings.Contains(rr.Body.String(), "Saved") { + t.Fatalf("%s: status=%d body=%s", tc.path, rr.Code, rr.Body.String()) + } + } +} + +func TestPingScorePathApproximatePrivateContributorsNotExposed(t *testing.T) { + srv, router := setupPingScoresFixture(t) + snap := testPingScorePathArchive(t, srv) + srv.cfg.SetHiddenNamePrefixes([]string{"Secret"}) + a := snap.pathArchives["allTime.farthestPing"] + for i := range a.Path.Branches { + a.Path.Branches[i].Observer.Approx = true + for j := range a.Path.Branches[i].Points { + a.Path.Branches[i].Points[j].Approx = true + } + } + a.Path.First.Observer.Approx = true + snap.pathArchives["allTime.farthestPing"] = a + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/archive01/path?record=allTime.farthestPing", nil)) + var response PingScorePathResponse + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if rr.Code != 200 || response.Status != "unavailable" || response.Reason != "privacy_filtered" || response.Path != nil { + t.Fatalf("private approximation leaked: %s", rr.Body.String()) + } +} + +func TestPingScorePathRecordedGeometrySurvivesGPSLossAndRealRestart(t *testing.T) { + for _, mode := range []string{"GPS loss", "packet retention"} { + t.Run(mode, func(t *testing.T) { + fx, id, before := seedRecordArchiveFixture(t) + if mode == "GPS loss" { + if _, err := fx.srv.db.conn.Exec(`DELETE FROM nodes WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + fx.clock.Advance(2 * time.Minute) + } else { + if _, err := fx.srv.db.conn.Exec(`DELETE FROM observations WHERE transmission_id=?; DELETE FROM transmissions WHERE id=?`, id, id); err != nil { + t.Fatal(err) + } + fx.clock.Advance(40 * 24 * time.Hour) + } + if _, err := fx.engine.Cycle(); err != nil { + t.Fatal(err) + } + var seq int + var name, file string + if err := fx.store.conn.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &file); err != nil { + t.Fatal(err) + } + if err := fx.store.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenPingScoreHistoryStore(file) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + engine, err := newPingScoreHistoryEngine(fx.srv, store, fx.clock.Now, fx.config) + if err != nil { + t.Fatal(err) + } + snap, err := engine.QuickSnapshot() + if err != nil { + t.Fatal(err) + } + fx.srv.pingScores.Store(snap) + router := mux.NewRouter() + fx.srv.RegisterRoutes(router) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/"+before.FarthestPing.Hash+"/path?record=allTime.farthestPing", nil)) + var response PingScorePathResponse + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if rr.Code != 200 || response.Status != "archived" || response.Path == nil { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + max := 0.0 + for _, b := range response.Path.Branches { + if b.DistanceFromFirstKm != nil && *b.DistanceFromFirstKm > max { + max = *b.DistanceFromFirstKm + } + } + if max != *before.FarthestPing.FarthestKm || *snap.FarthestPing.FarthestKm != max { + t.Fatalf("record/map mismatch after restart: map=%v card=%v old=%v", max, *snap.FarthestPing.FarthestKm, *before.FarthestPing.FarthestKm) + } + }) + } +} + +func TestPingScorePathNoArchiveCannotShowSmallerLiveGeometry(t *testing.T) { + fx, _, _ := seedRecordArchiveFixture(t) + // Simulate rollout onto existing score history after old GPS is gone: + // there was no route archive at the time of the original measurement. + fx.engine.pathArchives = nil + if _, err := fx.store.conn.Exec(`DELETE FROM ping_score_path_archives`); err != nil { + t.Fatal(err) + } + if _, err := fx.srv.db.conn.Exec(`DELETE FROM nodes WHERE public_key='pingobsb'`); err != nil { + t.Fatal(err) + } + fx.clock.Advance(2 * time.Minute) + snap, err := fx.engine.Cycle() + if err != nil { + t.Fatal(err) + } + if _, exists := snap.pathArchives["allTime.farthestPing"]; exists { + t.Fatal("incoherent archive unexpectedly created") + } + fx.srv.pingScores.Store(snap) + router := mux.NewRouter() + fx.srv.RegisterRoutes(router) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/ping-scores/"+snap.FarthestPing.Hash+"/path?record=allTime.farthestPing", nil)) + var response PingScorePathResponse + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if rr.Code != 200 || response.Status != "unavailable" || response.Reason != "record_evidence_unavailable" || response.Path != nil { + t.Fatalf("smaller map misrepresents retained record: %s", rr.Body.String()) + } +} diff --git a/cmd/server/ping_scores.go b/cmd/server/ping_scores.go index 053d678e1..1b893af5c 100644 --- a/cmd/server/ping_scores.go +++ b/cmd/server/ping_scores.go @@ -66,6 +66,10 @@ type PingScore struct { relayPubkeys []string firstPubkey string firstName string + + // A retained distance may use older landmark evidence than today's + // earliest observation. Never use this origin for first-hearer credit. + distanceFirstPubkey string } // PingLeaderboardEntry is one row of a leaderboard ranking. @@ -78,6 +82,11 @@ type PingLeaderboardEntry struct { // PingScoresSnapshot is the full cached ping-score board: current records // plus leaderboards, global (not scoped by region/area). type PingScoresSnapshot struct { + // Only the displayed record slots have a bounded durable path archive. + // Published together with these scores and immutable thereafter; HTTP + // handlers must copy before applying live visibility rules. + pathArchives map[string]PingScorePathArchive + GeneratedAt string `json:"generatedAt"` TotalPings int `json:"totalPings"` @@ -261,6 +270,7 @@ func (s *Server) buildPingScoreFromPath(trigger pingTriggerRow, resp *PacketPath if resp.First != nil && resp.First.Observer != nil { score.firstPubkey = resp.First.Observer.PublicKey score.firstName = resp.First.Observer.Name + score.distanceFirstPubkey = score.firstPubkey } return score } diff --git a/cmd/server/readonly_sql_literal_test.go b/cmd/server/readonly_sql_literal_test.go index 82e13e848..3875d4119 100644 --- a/cmd/server/readonly_sql_literal_test.go +++ b/cmd/server/readonly_sql_literal_test.go @@ -18,11 +18,14 @@ import ( var writeSQLPattern = regexp.MustCompile(`(?is)\b(INSERT\s+(OR\s+\w+\s+)?INTO|REPLACE\s+INTO|UPDATE\s+(OR\s+\w+\s+)?[\w"]+\s+SET|DELETE\s+FROM|CREATE\s+(TEMP\w*\s+|UNIQUE\s+|VIRTUAL\s+)*(TABLE|INDEX|TRIGGER|VIEW)|DROP\s+(TABLE|INDEX|TRIGGER|VIEW)|ALTER\s+TABLE|VACUUM|REINDEX|ATTACH\s+DATABASE)\b`) // knownServerWriteSQL lists the write-SQL string literals cmd/server already -// had when this guard was added, per file. The count may only go down: a -// new write belongs in cmd/ingestor (#1283). Lower the number here when a -// site is removed. +// had when this guard was added, per file. Shared-database counts may only +// go down: a new write belongs in cmd/ingestor (#1283). The separate ping +// history sidecar is the existing single-owner exception; its v3 additions +// are enumerated exactly below, never a general server-write allowance. // - ping_score_history.go: its own separate history database, not the // shared one. +// - ping_score_history_paths.go: v3's CREATE/ALTER and bounded archive +// replacement on that same sidecar transaction (four SQL literals). // - backup.go: VACUUM INTO writes a snapshot file, not the database. // - openapi.go: prose mentioning VACUUM INTO. // @@ -30,9 +33,10 @@ var writeSQLPattern = regexp.MustCompile(`(?is)\b(INSERT\s+(OR\s+\w+\s+)?INTO|RE // ingestor (#215); routes.go had 3 until POST /api/packets was removed // (#223). var knownServerWriteSQL = map[string]int{ - "backup.go": 1, - "openapi.go": 1, - "ping_score_history.go": 15, + "backup.go": 1, + "openapi.go": 1, + "ping_score_history.go": 15, + "ping_score_history_paths.go": 4, } // TestServerSourceHasNoNewWriteSQL guards the read-only server contract diff --git a/cmd/server/routes.go b/cmd/server/routes.go index c1146931c..8294cc146 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -303,6 +303,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/config/areas", s.handleConfigAreas).Methods("GET") r.HandleFunc("/api/config/areas/polygons", s.handleConfigAreasPolygons).Methods("GET") r.HandleFunc("/api/ping-scores", s.handlePingScores).Methods("GET") + r.HandleFunc("/api/ping-scores/{hash}/path", s.handlePingScorePath).Methods("GET") r.HandleFunc("/api/analytics/areas", s.handleAreaAnalytics).Methods("GET") r.HandleFunc("/api/analytics/gps-sanity", s.handleGPSSanity).Methods("GET") r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT") diff --git a/docs/api-spec.md b/docs/api-spec.md index 4a750c124..54e86d785 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -66,6 +66,58 @@ --- +## GET /api/ping-scores/:hash/path + +Returns path evidence for a currently displayed Ping Scores record. Requires +`record=allTime.` or `record=thisWeek.`, where `` is one of +`farthestPing`, `mostHopsPing`, `widestSpreadPing`, `fastestSpreadPing`, or +`mostEfficientPing`. The hash must match that slot's current record. + +```json +{ + "status": "archived", + "capturedAt": "2026-01-15T10:05:00Z", + "path": { + "hash": "example", + "branches": [{ "hops": 0, "points": [], "observer": { "name": "Example", "lat": 56.0, "lon": 10.0 } }] + } +} +``` + +`status` is `archived` (saved route geometry), `live` (currently available +observations), `initializing` (board not published yet), or `unavailable`. +`capturedAt` is the archive capture time, not a radio transmission timestamp. +Unavailable responses omit `path` and include a `reason`: expired raw data +before capture, no coordinates, privacy filtering, or archive size limits +(`raw_data_expired_before_capture`, `no_coordinates`, `privacy_filtered`, +`archive_too_large`). `record_evidence_unavailable` means current observations +no longer reproduce the saved record and no matching archive exists; a +smaller live map is not substituted. Invalid slots return 400; superseded slot/hash pairs +return 404. A visibility lookup failure returns 500 rather than exposing data. + +The existing ping history sidecar stores at most ten record-slot paths, each +limited to 256 KiB, 128 branches and 4,096 points (including `first`). Paths +are saved atomically with record changes and restored after restart. Old +records whose observations were already pruned cannot be reconstructed. +Superseded records are not a permanent path archive. Packet retention and the +main database schema are unchanged. + +The sidecar's additive v3 migration also adds a nullable distance-origin +pubkey, without scanning existing rows. This keeps historical distance +evidence separate from current first-hearer leaderboard credit. Missing +GPS, including fallback to an IATA airport, cannot lower an established +distance; genuine corrections with both historical endpoints positioned are +still allowed. + +Archived and live paths both apply the current node/observer blacklists and +hidden-name prefixes, including saved names and current/inactive names. +Hidden branches are omitted entirely; hidden first observations and their +relative measurements are removed. Approximate neighbor-centroid positions +are omitted when a visibility policy is active because the path format does +not identify their contributors. Touched areas are recomputed from visible +positions and current area configuration. Existing general packet-path +endpoints are unchanged. + ## Conventions ### Types diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 7d512cc57..06c47c5e7 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -75,6 +75,9 @@ } var activeMap = null; + var requestGeneration = 0; + var routePrefix = '#/packets/'; + var routeQueryKey = null; // Registered in the capture phase while the modal is open: Escape closes // only this top layer. Stopping it keeps the layers below (the packets @@ -115,7 +118,7 @@ if (typeof history === 'undefined' || !history.replaceState) return; var h = String(location.hash || ''); var q = h.indexOf('?'); - if (q < 0 || h.indexOf('#/packets/') !== 0) return; + if (q < 0 || h.indexOf(routePrefix) !== 0) return; var params = h.slice(q + 1).split('&'); var kept = params.filter(function (p) { return p !== 'viewPath=1'; }); if (kept.length === params.length) return; @@ -126,6 +129,7 @@ // (#/packets/, without the query) at open(). A hashchange to another // path -- Back/Forward, a nav link -- closes it. var openPath = null; + var openQueryValue = null; // The id open() put in the history.state of the #/packets/ entry it // opened on, and the ids of entries whose modal was closed while another // route was shown. Such an entry still carries ?viewPath=1, so Back/Forward @@ -141,8 +145,16 @@ return q < 0 ? h : h.slice(0, q); } + function modalQueryValue() { + return new URLSearchParams(String(location.hash || '').split('?')[1] || '').get(routeQueryKey); + } + + function onModalRoute() { + return hashPath(location.hash) === openPath && (!routeQueryKey || modalQueryValue() === openQueryValue); + } + function tagEntry() { - if (typeof history === 'undefined' || !history.replaceState || openPath.indexOf('#/packets/') !== 0) return null; + if (typeof history === 'undefined' || !history.replaceState || openPath.indexOf(routePrefix) !== 0) return null; var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); var state = {}; var cur = history.state; @@ -154,11 +166,12 @@ } function onHashChange() { - if (hashPath(location.hash) !== openPath) close(); + if (!onModalRoute()) close(); } // Removes the modal; true when one was open. function removeModal() { + requestGeneration++; var overlay = document.getElementById('packetPathModal'); if (overlay) overlay.remove(); if (activeMap) { @@ -171,10 +184,10 @@ } function close() { - var elsewhere = hashPath(location.hash) !== openPath; + var elsewhere = !onModalRoute(); var entry = openEntry; if (!removeModal()) return; - openPath = openEntry = null; + openPath = openEntry = openQueryValue = null; if (!elsewhere) { dropViewPathParam(); } else if (entry) { @@ -187,10 +200,10 @@ // (packets.js init(): a shared link, a reload, Back/Forward), unless that // entry's modal was closed while another route was shown (#180). Returns // whether it opened; when not, packets.js drops ?viewPath=1 from the URL. - function restore(hash) { + function restore(hash, options) { var state = typeof history !== 'undefined' ? history.state : null; if (state && state.packetPathModal && closedEntries.indexOf(state.packetPathModal) !== -1) return false; - open(hash); + open(hash, options); return true; } @@ -246,10 +259,17 @@ return { chain: chain, missing: (b.points || []).length - located.length }; } - async function open(hash) { + // Optional ping-history loader returns a status envelope instead of the + // ordinary PacketPathResponse. Ordinary packet callers need no options. + async function open(hash, options) { // In case one's already open. Replacing it is not a close, so a // ?viewPath=1 in the URL stays (#167). removeModal(); + var generation = requestGeneration; + options = options || {}; + routePrefix = options.routePrefix || '#/packets/'; + routeQueryKey = options.routeQueryKey || null; + var historical = typeof options.loadPath === 'function'; var overlay = document.createElement('div'); overlay.id = 'packetPathModal'; @@ -266,7 +286,8 @@ '' + '

Relay Path

' + '

How far and how wide this packet spread. Click a marker to open that node\'s detail page.

' + - '
' + + '' + + '
' + 'farthest-traveled route' + '' + 'other station' + @@ -276,6 +297,7 @@ '
' + '
' + '
Loading…
' + + '' + '
'; document.body.appendChild(overlay); overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); @@ -283,6 +305,9 @@ if (closeBtn) closeBtn.addEventListener('click', close); document.addEventListener('keydown', onKeydown, true); openPath = hashPath(location.hash); + // Two record slots can share a packet hash but have different archived + // evidence. A Back to the other slot is navigation, not a close in place. + openQueryValue = routeQueryKey ? modalQueryValue() : null; openEntry = tagEntry(); window.addEventListener('hashchange', onHashChange); @@ -293,7 +318,7 @@ var copyLinkBtn = document.getElementById('packetPathCopyLink'); if (copyLinkBtn) { copyLinkBtn.addEventListener('click', function () { - var url = location.origin + '/#/packets/' + encodeURIComponent(hash) + '?viewPath=1'; + var url = options.shareURL || location.origin + '/#/packets/' + encodeURIComponent(hash) + '?viewPath=1'; if (typeof window.copyToClipboard === 'function') { window.copyToClipboard(url, function () { copyLinkBtn.innerHTML = ''; @@ -306,14 +331,65 @@ } var statusEl = document.getElementById('packetPathStatus'); + function isCurrent() { + return generation === requestGeneration && document.getElementById('packetPathModal') === overlay; + } + function withoutMap(message, canRetry) { + var mapEl = document.getElementById('packetPathMapContainer'); + var legendEl = document.getElementById('packetPathLegend'); + var retryBtn = document.getElementById('packetPathRetry'); + if (mapEl) mapEl.style.display = 'none'; + if (legendEl) legendEl.style.display = 'none'; + if (statusEl) statusEl.textContent = message; + if (retryBtn && canRetry) { + retryBtn.style.display = 'inline-block'; + retryBtn.addEventListener('click', function () { return open(hash, options); }); + } + } var data; try { - data = await api('/packets/' + encodeURIComponent(hash) + '/path'); + data = historical ? await options.loadPath(hash) : await api('/packets/' + encodeURIComponent(hash) + '/path'); } catch (e) { - if (statusEl) statusEl.textContent = 'Failed to load path: ' + e.message; + if (!isCurrent()) return; + if (historical) withoutMap('Failed to load path: ' + e.message, true); + else if (statusEl) statusEl.textContent = 'Failed to load path: ' + e.message; return; } + if (!isCurrent()) return; + + if (historical) { + if (data && data.status === 'initializing') { + withoutMap('Ping history is initializing. Try again shortly.', true); + return; + } + if (data && data.status === 'unavailable') { + var explanations = { + raw_data_expired_before_capture: 'The original observations expired before a historical map could be saved. The score is retained, but its old path cannot be recovered.', + no_coordinates: 'This record has no known positions available to draw a map.', + privacy_filtered: 'This map is unavailable under the current privacy settings.', + archive_too_large: 'This path exceeded the historical map size limit. The score is retained, but its map was not saved.', + record_evidence_unavailable: 'Current observations no longer reproduce this record; no matching historical map was saved.' + }; + var explanation = Object.prototype.hasOwnProperty.call(explanations, data.reason) ? explanations[data.reason] : 'A historical map is not available for this record.'; + withoutMap(explanation, false); + return; + } + if (!data || (data.status !== 'live' && data.status !== 'archived') || !data.path || !Array.isArray(data.path.branches)) { + withoutMap('Failed to load path: invalid history response.', true); + return; + } + if (data.status === 'archived') { + var note = document.getElementById('packetPathArchiveNote'); + var captured = new Date(typeof data.capturedAt === 'string' ? data.capturedAt : NaN); + var savedAt = isNaN(captured.getTime()) ? 'at an unknown time' : captured.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'); + if (note) { + note.style.display = 'block'; + note.textContent = 'Archived map — saved ' + savedAt + '. Positions reflect when the map was saved, not necessarily when the packet was sent.'; + } + } + data = data.path; + } var branches = data.branches || []; var plotted = branches.map(function (b) { @@ -364,6 +440,10 @@ } if (plotted.length === 0) { + if (historical) { + withoutMap('This record has no known positions available to draw a map.', false); + return; + } if (statusEl) { if (branches.length === 0) { statusEl.textContent = 'This packet has no observations yet.'; @@ -378,7 +458,8 @@ } if (typeof L === 'undefined') { - if (statusEl) statusEl.textContent = 'Map library unavailable.'; + if (historical) withoutMap('Map library unavailable.', false); + else if (statusEl) statusEl.textContent = 'Map library unavailable.'; return; } @@ -532,9 +613,15 @@ .bindTooltip(phIcon('flag') + 'First to hear it: ' + escapeHtml(firstPoint.name) + ' (' + data.first.hops + ' hop' + (data.first.hops === 1 ? '' : 's') + (firstPoint.approx ? ', approx. position' : '') + ')', { className: 'packet-path-tooltip' }); } - try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ } - setTimeout(function () { map.invalidateSize(); }, 120); + // Historical links can be closed/restored immediately during navigation. + // Leaflet 1.9's deferred initial zoom can otherwise finish after remove(). + // Keep ordinary packet options unchanged; no animation is needed to first + // display a saved map at its complete bounds. + var fitOptions = { padding: [30, 30] }; + if (historical) fitOptions.animate = false; + try { map.fitBounds(bounds, fitOptions); } catch (e) { /* single point */ } activeMap = map; + setTimeout(function () { if (activeMap === map) map.invalidateSize(); }, 120); // Label the highlight(s) honestly, matching the role split above: // no distance data at all -> one accent-colored "deepest (most diff --git a/public/ping-scores.js b/public/ping-scores.js index cfe537bb6..161c6818f 100644 --- a/public/ping-scores.js +++ b/public/ping-scores.js @@ -34,9 +34,9 @@ return Math.floor(diffSec / 86400) + 'd ago'; } - function viewPathLink(hash) { + function viewPathLink(hash, record) { if (!hash) return ''; - return ''; + return ''; } // recordDefs: each record's headline metric formatter + a short @@ -60,7 +60,28 @@ sub: function (p) { return p.farthestKm != null ? '~' + formatKm(p.farthestKm) + ' total' : ''; } } ]; - function recordCardHtml(def, ping) { + function validRecord(record) { + return recordDefs.some(function (def) { + return record === 'allTime.' + def.key || record === 'thisWeek.' + def.key; + }); + } + + function mapRoute(hash, record) { + return '#/ping-scores/' + encodeURIComponent(hash) + '?viewPath=1&record=' + encodeURIComponent(record); + } + + function mapOptions(hash, record) { + return { + routePrefix: '#/ping-scores/', + routeQueryKey: 'record', + shareURL: location.origin + '/' + mapRoute(hash, record), + loadPath: function (h) { + return api('/ping-scores/' + encodeURIComponent(h) + '/path?record=' + encodeURIComponent(record)); + } + }; + } + + function recordCardHtml(def, ping, scope) { if (!ping) { return '
' + '
' + def.icon + ' ' + def.title + '
' + @@ -77,7 +98,7 @@ formatAgo(ping.timestamp) + '
' + '
' + def.desc + '
' + - '
' + viewPathLink(ping.hash) + '
' + + '
' + viewPathLink(ping.hash, scope + '.' + def.key) + '
' + '
'; } @@ -101,11 +122,13 @@ ''; } - function render(container, data) { + function render(container, data, invalidLink) { + var linkNotice = invalidLink ? '

Invalid ping record link. Open a record from the board instead.

' : ''; if (!data || data.totalPings === 0) { container.innerHTML = '
' + '

' + phIcon('trophy') + ' Ping Scores

' + + linkNotice + '

Global records and leaderboards from every "ping" sent in any channel. Not scoped by region.

' + '
' + '

No pings recorded yet.

' + @@ -116,7 +139,7 @@ } var recordsHtml = recordDefs.map(function (def) { - return recordCardHtml(def, data[def.key]); + return recordCardHtml(def, data[def.key], 'allTime'); }).join(''); // ThisWeek mirrors the same 5 slots, scoped to the trailing 7 days -- @@ -127,12 +150,13 @@ // yet" placeholder via the `|| {}` fallback. var week = data.thisWeek || {}; var weekHtml = recordDefs.map(function (def) { - return recordCardHtml(def, week[def.key]); + return recordCardHtml(def, week[def.key], 'thisWeek'); }).join(''); container.innerHTML = '
' + '

' + phIcon('trophy') + ' Ping Scores

' + + linkNotice + '

Global records and leaderboards from every "ping" sent in any channel (' + data.totalPings + ' total). Not scoped by region. Updated ' + escapeHtml(formatAgo(data.generatedAt)) + '.

' + '

' + phIcon('arrow-clockwise') + ' Last 7 Days

' + '

Rolling window, not a calendar week.

' + @@ -148,20 +172,44 @@ container.querySelectorAll('[data-view-path]').forEach(function (btn) { btn.addEventListener('click', function () { - if (window.PacketPathMap) window.PacketPathMap.open(btn.dataset.viewPath); + if (!window.PacketPathMap) return; + var hash = btn.dataset.viewPath; + var record = btn.dataset.viewRecord; + // Keep this entry in history, without reloading the board just to + // open its on-demand map. Cold links are restored by init below. + history.pushState(null, '', mapRoute(hash, record)); + window.PacketPathMap.open(hash, mapOptions(hash, record)); }); }); } + var pageGeneration = 0; + registerPage('ping-scores', { - init: function (container) { + init: function (container, param) { + var generation = ++pageGeneration; + var query = new URLSearchParams(String(location.hash || '').split('?')[1] || ''); + var wantsMap = !!param && query.get('viewPath') === '1'; + var record = query.get('record'); + var invalidLink = wantsMap && !validRecord(record); container.innerHTML = '

' + phIcon('trophy') + ' Ping Scores

Loading…

'; return api('/ping-scores').then(function (data) { - render(container, data); + if (generation !== pageGeneration) return; + render(container, data, invalidLink); + if (!invalidLink && wantsMap && window.PacketPathMap) { + if (!window.PacketPathMap.restore(param, mapOptions(param, record))) { + query.delete('viewPath'); + history.replaceState(null, '', String(location.hash).split('?')[0] + (query.toString() ? '?' + query.toString() : '')); + } + } }).catch(function (e) { + if (generation !== pageGeneration) return; container.innerHTML = '

' + phIcon('trophy') + ' Ping Scores

Failed to load: ' + escapeHtml(e.message) + '

'; }); }, - destroy: function () {} + destroy: function () { + pageGeneration++; + if (window.PacketPathMap) window.PacketPathMap.close(); + } }); })(); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 7b1b694d8..f374b1fac 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -153,7 +153,7 @@ function makeSandbox(apiImpl) { removeEventListener(type, fn) { if (winListeners[type]) winListeners[type] = winListeners[type].filter(f => f !== fn); }, }; const ctx = { - window: win, document: doc, console, Math, String, JSON, Promise, Error, Date, + window: win, document: doc, console, Math, String, JSON, Promise, Error, Date, URLSearchParams, setTimeout, clearTimeout, // Returns the variable name itself (not a real color) so tests can // assert two markers use DIFFERENT css vars without caring what the @@ -193,6 +193,178 @@ function makeSandbox(apiImpl) { } (async () => { + async function historyCase(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } + } + function installHistoryLeaflet(ctx) { + const counts = { maps: 0, removed: 0, invalidated: 0 }; + const map = { setView() { return this; }, fitBounds(_, options) { counts.fitOptions = options; }, invalidateSize() { counts.invalidated++; }, remove() { counts.removed++; } }; + const layer = () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }); + ctx.L = { map() { counts.maps++; return map; }, tileLayer: layer, circleMarker: layer, polyline: layer }; + return counts; + } + const archivedPath = { hash: 'historic', branches: [{ hops: 0, points: [], observer: { name: 'Saved station', lat: 56, lon: 10 } }] }; + const pingOptions = (loadPath) => ({ loadPath, routePrefix: '#/ping-scores/', routeQueryKey: 'record', shareURL: 'https://stg.meshview.dk/#/ping-scores/historic?viewPath=1&record=allTime.farthestPing' }); + + await historyCase('ping archive loader renders a saved-time disclaimer and copies a ping-specific URL', async () => { + const ctx = makeSandbox(() => { throw new Error('ordinary packet API must not be used'); }); + const counts = installHistoryLeaflet(ctx); + ctx.location.hash = '#/ping-scores/historic?viewPath=1&record=allTime.farthestPing'; + let requested; + await ctx.window.PacketPathMap.open('historic', pingOptions(async h => { + requested = h; + return { status: 'archived', capturedAt: '2026-10-06T08:00:00Z', path: archivedPath }; + })); + assert.strictEqual(requested, 'historic'); + assert.strictEqual(counts.maps, 1); + assert.strictEqual(counts.fitOptions.animate, false, 'historical initial bounds must not animate after navigation'); + const note = ctx.document.getElementById('packetPathArchiveNote'); + assert.ok(note.textContent.includes('2026-10-06 08:00:00 UTC')); + assert.ok(note.textContent.includes('not necessarily when the packet was sent')); + ctx.document.getElementById('packetPathCopyLink')._listeners.click[0](); + assert.strictEqual(ctx.__copiedText, pingOptions().shareURL); + ctx.window.PacketPathMap.close(); + assert.strictEqual(ctx.location.hash, '#/ping-scores/historic?record=allTime.farthestPing'); + assert.strictEqual(counts.removed, 1); + }); + + await historyCase('expired, unpositioned, private, and oversized records explain unavailable maps without an empty map box', async () => { + const messages = { raw_data_expired_before_capture: 'expired before', no_coordinates: 'known positions', privacy_filtered: 'privacy settings', archive_too_large: 'size limit', record_evidence_unavailable: 'no longer reproduce this record' }; + for (const [reason, expected] of Object.entries(messages)) { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const counts = installHistoryLeaflet(ctx); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => ({ status: 'unavailable', reason }))); + assert.ok(ctx.document.getElementById('packetPathStatus').textContent.includes(expected), reason); + assert.strictEqual(ctx.document.getElementById('packetPathMapContainer').style.display, 'none', reason); + assert.strictEqual(ctx.document.getElementById('packetPathLegend').style.display, 'none', reason); + assert.strictEqual(counts.maps, 0, reason); + ctx.window.PacketPathMap.close(); + } + }); + + await historyCase('initializing history is not confused with expired history or a network failure', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => ({ status: 'initializing' }))); + assert.ok(ctx.document.getElementById('packetPathStatus').textContent.includes('initializing')); + assert.strictEqual(ctx.document.getElementById('packetPathMapContainer').style.display, 'none'); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => { throw new Error('network offline'); })); + assert.ok(ctx.document.getElementById('packetPathStatus').textContent.includes('Failed to load path: network offline')); + assert.strictEqual(ctx.document.getElementById('packetPathMapContainer').style.display, 'none'); + ctx.window.PacketPathMap.close(); + }); + + await historyCase('a live ping path renders normally without pretending to be an archive', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const counts = installHistoryLeaflet(ctx); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => ({ status: 'live', path: archivedPath }))); + assert.strictEqual(counts.maps, 1); + assert.strictEqual(ctx.document.getElementById('packetPathArchiveNote').textContent, ''); + assert.ok(ctx.document.getElementById('packetPathModal').innerHTML.includes('id="packetPathArchiveNote" class="text-muted" style="display:none;')); + ctx.window.PacketPathMap.close(); + }); + + await historyCase('ping Back/Forward entries use the same closed-entry lifecycle as ordinary packet links', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const url = '#/ping-scores/historic?viewPath=1&record=allTime.farthestPing'; + const opts = pingOptions(async () => ({ status: 'unavailable', reason: 'no_coordinates' })); + ctx.location.hash = url; + await ctx.window.PacketPathMap.open('historic', opts); + const state = ctx.history.state; + assert.ok(state.packetPathModal, 'ping history entry must be tagged'); + ctx.__navigate('#/ping-scores'); + assert.ok(!ctx.document.getElementById('packetPathModal')); + ctx.__navigate(url, state); + assert.strictEqual(ctx.window.PacketPathMap.restore('historic', opts), false); + ctx.__navigate(url, null); + assert.strictEqual(ctx.window.PacketPathMap.restore('historic', opts), true); + await Promise.resolve(); + const escape = ctx.__docLog.find(e => e.type === 'keydown').fn; + escape({ key: 'Escape', stopPropagation() {} }); + assert.ok(!ctx.document.getElementById('packetPathModal')); + assert.strictEqual(ctx.location.hash, '#/ping-scores/historic?record=allTime.farthestPing'); + }); + + await historyCase('late path responses cannot create a map in a replacement or closed modal', async () => { + let resolve; + const ctx = makeSandbox(() => Promise.resolve({ hash: 'new', branches: [] })); + const counts = installHistoryLeaflet(ctx); + const old = ctx.window.PacketPathMap.open('historic', pingOptions(() => new Promise(r => { resolve = r; }))); + await ctx.window.PacketPathMap.open('new'); + resolve({ status: 'archived', capturedAt: '2026-10-06T08:00:00Z', path: archivedPath }); + await old; + assert.strictEqual(counts.maps, 0); + assert.ok(ctx.document.getElementById('packetPathStatus').textContent.includes('no observations')); + const closed = ctx.window.PacketPathMap.open('historic', pingOptions(() => new Promise(r => { resolve = r; }))); + ctx.window.PacketPathMap.close(); + resolve({ status: 'live', path: archivedPath }); + await closed; + assert.strictEqual(counts.maps, 0); + }); + + await historyCase('Back between two slots for the same hash preserves the incoming record URL and restores the correct map', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const urlA = '#/ping-scores/historic?viewPath=1&record=allTime.farthestPing'; + const urlB = '#/ping-scores/historic?viewPath=1&record=thisWeek.farthestPing'; + const options = pingOptions(async () => ({ status: 'unavailable', reason: 'no_coordinates' })); + ctx.location.hash = urlA; + await ctx.window.PacketPathMap.open('historic', options); + const stateA = ctx.history.state; + // A card click uses pushState then open(), not a hashchange: replacing + // A with B must not tag the incoming A entry as closed on a later Back. + ctx.location.hash = urlB; + ctx.history.state = null; + await ctx.window.PacketPathMap.open('historic', options); + const stateB = ctx.history.state; + ctx.location.hash = urlA; + ctx.history.state = stateA; + ctx.window.PacketPathMap.close(); // app.navigate() destroys before init(). + assert.strictEqual(ctx.location.hash, urlA, 'closing departing B changed incoming A URL'); + assert.strictEqual(ctx.window.PacketPathMap.restore('historic', options), true, 'incoming A was wrongly marked closed'); + await Promise.resolve(); + ctx.__navigate(urlB, stateB); + assert.strictEqual(ctx.window.PacketPathMap.restore('historic', options), false, 'the departing B entry retains the established closed-entry semantics'); + }); + + await historyCase('a scheduled resize does not use a map after the modal was destroyed', async () => { + const ctx = makeSandbox(() => Promise.resolve(archivedPath)); + const counts = installHistoryLeaflet(ctx); + const timers = []; + ctx.setTimeout = fn => { timers.push(fn); }; + await ctx.window.PacketPathMap.open('historic'); + assert.strictEqual(counts.maps, 1); + assert.strictEqual(Object.prototype.hasOwnProperty.call(counts.fitOptions, 'animate'), false, 'ordinary packet animation defaults must not change'); + ctx.window.PacketPathMap.close(); + timers.forEach(fn => fn()); + assert.strictEqual(counts.invalidated, 0); + }); + + await historyCase('initializing history can be retried without refreshing the board or using the ordinary packet endpoint', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const counts = installHistoryLeaflet(ctx); + let calls = 0; + const options = pingOptions(async () => ++calls === 1 ? { status: 'initializing' } : { status: 'live', path: archivedPath }); + await ctx.window.PacketPathMap.open('historic', options); + const retry = ctx.document.getElementById('packetPathRetry'); + assert.strictEqual(retry.style.display, 'inline-block'); + await retry._listeners.click[0](); + assert.strictEqual(calls, 2); + assert.strictEqual(counts.maps, 1); + ctx.window.PacketPathMap.close(); + }); + + await historyCase('invalid history envelopes and unknown reasons never render a misleading map or raw server text', async () => { + const ctx = makeSandbox(() => { throw new Error('wrong API'); }); + const counts = installHistoryLeaflet(ctx); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => ({ status: 'live' }))); + assert.strictEqual(ctx.document.getElementById('packetPathStatus').textContent, 'Failed to load path: invalid history response.'); + assert.strictEqual(ctx.document.getElementById('packetPathMapContainer').style.display, 'none'); + await ctx.window.PacketPathMap.open('historic', pingOptions(async () => ({ status: 'unavailable', reason: '__proto__' }))); + assert.strictEqual(ctx.document.getElementById('packetPathStatus').textContent, 'A historical map is not available for this record.'); + assert.strictEqual(counts.maps, 0); + ctx.window.PacketPathMap.close(); + }); + await (async () => { try { const ctx = makeSandbox(() => Promise.reject(new Error('network down'))); diff --git a/test-ping-scores.js b/test-ping-scores.js index 5e4605524..59b015d19 100644 --- a/test-ping-scores.js +++ b/test-ping-scores.js @@ -28,7 +28,7 @@ test('registers the ping-scores page', () => { }); test('fetches via the shared api() helper, not a raw fetch', () => { - assert.ok(/api\(\s*'\/ping-scores'\s*\)/.test(src)); + assert.ok(/api\(\s*'\/ping-scores'/.test(src)); }); test('escapes sender name before interpolating into a record card', () => { @@ -64,10 +64,11 @@ function makeSandbox(apiImpl) { } // Register data-view-path buttons too, since the smoke test needs // to click one and verify PacketPathMap.open was called. - const vpRe = /data-view-path="([^"]*)"/g; + const vpRe = /data-view-path="([^"]*)"(?:\s+data-view-record="([^"]*)")?/g; while ((m = vpRe.exec(html))) { const child = makeElement('button'); child.dataset.viewPath = m[1]; + if (m[2]) child.dataset.viewRecord = m[2]; this.appendChild(child); } }, @@ -92,8 +93,14 @@ function makeSandbox(apiImpl) { const container = makeElement('div'); let registered = null; const ctx = { - window: { PacketPathMap: { open: (h) => { ctx.window._openedHash = h; } } }, - console, Math, String, JSON, Promise, Error, Date, isNaN, + window: { PacketPathMap: { + open: (h, options) => { ctx.window._openedHash = h; ctx.window._openedOptions = options; }, + restore: (h, options) => { ctx.window.PacketPathMap.open(h, options); return true; }, + close: () => { ctx.window._closed = true; }, + } }, + console, Math, String, JSON, Promise, Error, Date, isNaN, URLSearchParams, + location: { origin: 'http://localhost:13581', hash: '#/ping-scores' }, + history: { state: null, pushState(state, _, url) { this.state = state; ctx.location.hash = url; }, replaceState(state, _, url) { this.state = state; if (url !== undefined) ctx.location.hash = url; } }, escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'), api: apiImpl, registerPage: (name, mod) => { registered = mod; }, @@ -114,6 +121,111 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ shows a friendly empty state when totalPings is 0: ' + e.message); } })(); + await (async () => { + try { + const hash = 'same-record-hash'; + const record = { hash, timestamp: new Date().toISOString(), farthestKm: 50, stationCount: 2, deepestHops: 1 }; + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve({ totalPings: 1, farthestPing: record, thisWeek: { farthestPing: record } })); + await getPage().init(container); + const buttons = container.querySelectorAll('[data-view-path]'); + assert.deepStrictEqual(buttons.map(b => b.dataset.viewRecord), ['thisWeek.farthestPing', 'allTime.farthestPing']); + buttons[1].click(); + const opts = ctx.window._openedOptions; + assert.strictEqual(ctx.location.hash, '#/ping-scores/same-record-hash?viewPath=1&record=allTime.farthestPing'); + assert.strictEqual(opts.shareURL, 'http://localhost:13581/#/ping-scores/same-record-hash?viewPath=1&record=allTime.farthestPing'); + assert.strictEqual(opts.routePrefix, '#/ping-scores/'); + assert.strictEqual(opts.routeQueryKey, 'record'); + let requested; + ctx.api = (url) => { requested = url; return Promise.resolve({ status: 'archived' }); }; + await opts.loadPath(hash); + assert.strictEqual(requested, '/ping-scores/same-record-hash/path?record=allTime.farthestPing'); + passed++; + console.log(' ✅ record buttons preserve all-time/weekly slot and load the ping-specific path API'); + } catch (e) { failed++; console.log(' ❌ record buttons preserve all-time/weekly slot: ' + e.message); } + })(); + + await (async () => { + try { + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve({ totalPings: 1 })); + ctx.location.hash = '#/ping-scores/oldhash?viewPath=1&record=thisWeek.mostHopsPing'; + await getPage().init(container, 'oldhash'); + assert.strictEqual(ctx.window._openedHash, 'oldhash'); + assert.ok(ctx.window._openedOptions.shareURL.includes('record=thisWeek.mostHopsPing')); + passed++; + console.log(' ✅ cold ping map links open without resolving a retained packet first'); + } catch (e) { failed++; console.log(' ❌ cold ping map links: ' + e.message); } + })(); + + await (async () => { + try { + const keys = ['farthestPing', 'mostHopsPing', 'widestSpreadPing', 'fastestSpreadPing', 'mostEfficientPing']; + const data = { totalPings: 1, thisWeek: {} }; + const ping = { hash: 'samehash', farthestKm: 10, deepestHops: 2, stationCount: 3, spreadSeconds: 2, kmPerSecondAirtime: 5 }; + keys.forEach(k => { data[k] = ping; data.thisWeek[k] = ping; }); + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve(data)); + await getPage().init(container); + const buttons = container.querySelectorAll('[data-view-path]'); + assert.strictEqual(buttons.length, 10); + for (const btn of buttons) { + btn.click(); + assert.ok(ctx.window._openedOptions.shareURL.endsWith('record=' + btn.dataset.viewRecord)); + } + assert.deepStrictEqual(buttons.map(b => b.dataset.viewRecord), ['thisWeek', 'allTime'].flatMap(scope => keys.map(k => scope + '.' + k))); + passed++; + console.log(' ✅ all ten record slots remain distinct even when every card shares the same hash'); + } catch (e) { failed++; console.log(' ❌ all ten record slots: ' + e.message); } + })(); + + await (async () => { + try { + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve({ totalPings: 1 })); + ctx.location.hash = '#/ping-scores/oldhash?viewPath=1&record=allTime.__proto__'; + await getPage().init(container, 'oldhash'); + assert.strictEqual(ctx.window._openedHash, undefined); + assert.ok(container.innerHTML.includes('Invalid ping record link')); + passed++; + console.log(' ✅ invalid record slots cannot select another endpoint or prototype member'); + } catch (e) { failed++; console.log(' ❌ invalid record slots: ' + e.message); } + })(); + + await (async () => { + try { + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve({ totalPings: 1 })); + ctx.location.hash = '#/ping-scores/oldhash?viewPath=1&record=allTime.farthestPing'; + ctx.window.PacketPathMap.restore = () => false; + await getPage().init(container, 'oldhash'); + assert.strictEqual(ctx.location.hash, '#/ping-scores/oldhash?record=allTime.farthestPing'); + passed++; + console.log(' ✅ a closed history entry removes only viewPath and preserves its record slot'); + } catch (e) { failed++; console.log(' ❌ closed history entry: ' + e.message); } + })(); + + await (async () => { + try { + const { ctx, container, getPage } = makeSandbox(() => Promise.resolve({ totalPings: 0 })); + await getPage().init(container); + getPage().destroy(); + assert.strictEqual(ctx.window._closed, true); + passed++; + console.log(' ✅ leaving Ping Scores closes its map'); + } catch (e) { failed++; console.log(' ❌ destroy closes map: ' + e.message); } + })(); + + await (async () => { + try { + let resolve; + const { container, getPage } = makeSandbox(() => new Promise(r => { resolve = r; })); + const pending = getPage().init(container); + getPage().destroy(); + container.innerHTML = 'different page'; + resolve({ totalPings: 1, farthestPing: { hash: 'late', farthestKm: 50 } }); + await pending; + assert.strictEqual(container.innerHTML, 'different page'); + passed++; + console.log(' ✅ late score responses cannot overwrite another page after destroy'); + } catch (e) { failed++; console.log(' ❌ late response after destroy: ' + e.message); } + })(); + await (async () => { try { const data = {