diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a690bf..754984c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: cache-dependency-path: web/package-lock.json - working-directory: web run: npm ci + - name: Audit shipped frontend dependencies + working-directory: web + run: npm audit --omit=dev --audit-level=high - working-directory: web run: npm test - working-directory: web @@ -161,9 +164,29 @@ jobs: if: always() needs: [backend, frontend, container] runs-on: ubuntu-latest + permissions: + contents: read + statuses: write steps: - - env: + - name: Require every validation job + id: gate + env: BACKEND: ${{ needs.backend.result }} FRONTEND: ${{ needs.frontend.result }} CONTAINER: ${{ needs.container.result }} run: test "$BACKEND" = success -a "$FRONTEND" = success -a "$CONTAINER" = success + - name: Attach dispatched validation to the exact commit + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GATE_RESULT: ${{ steps.gate.outcome }} + run: | + state=failure + if [ "$GATE_RESULT" = success ]; then + state=success + fi + gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state="$state" -f context='CI / Required' \ + -f description='Maintainer-dispatched backend, frontend and container validation' \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --silent diff --git a/CHANGELOG.md b/CHANGELOG.md index 7537a77..ba8cfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.2 - 2026-09-09 + +- Add the paired audit roadmap and begin its security and data-correctness milestone. +- Attach maintainer-dispatched CI results to the exact commit so pull requests expose their real validation status. +- Upgrade to MapLibre 6.4.1, configure its bundled worker, and check shipped frontend dependencies in CI. The map now requires WebGL2; Netgraph retains Canvas2D. +- Omit ambiguous source legs even when a conflicting identity has no coordinates. Preserve independently confirmed downstream hops. +- Reject non-finite RF values and MQTT envelopes larger than 64 KiB before normalization. +- Reconcile connected browsers after capacity evictions using a coalesced reset and matching snapshot. +- Expire retained topology during quiet periods, avoid clean checkpoint rewrites, and retain failure/retry visibility. +- Add synthetic attribution-sanitizer, source-collision, RF, input-size, capacity, retention and checkpoint-retry regression coverage. + ## 0.3.1 - 2026-09-06 - Clear expired Live Follow cards and map highlights when the feed goes quiet, while keeping Follow ready for fresh activity. diff --git a/README.md b/README.md index 3f46f74..46181e2 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ The default bind is loopback-only. Keep it that way behind a TLS reverse proxy, ## MQTT input +MQTT bridge envelopes are limited to 64 KiB before normalization, and RF evidence must contain finite numbers. The geographic map requires WebGL2; Netgraph uses Canvas2D. + The subscriber defaults to `meshcore/#` and accepts the standard topic shape: ```text @@ -107,6 +109,7 @@ This repository contains the standalone server, browser map, and worldwide Netgr ## Documentation +- [Audit roadmap and current work](docs/roadmap.md) - [Deployment](docs/deployment.md) - [Architecture](docs/architecture.md) - [Privacy boundary](docs/privacy.md) diff --git a/VERSION b/VERSION index 9e11b32..d15723f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.3.2 diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index 8a49118..2377a45 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -75,6 +75,7 @@ type Engine struct { prunedNodes atomic.Int64 prunedRoutes atomic.Int64 checkpointDirty bool + snapshotResetPending bool done chan struct{} publish func(Event) @@ -172,9 +173,7 @@ func (e *Engine) Run(ctx context.Context) { for { select { case <-ctx.Done(): - if dirtyCheckpoint { - _, _ = e.flushCheckpoint(time.Now()) - } + _, _ = e.flushCheckpoint(time.Now(), dirtyCheckpoint) e.updateSnapshot(time.Now()) return case message := <-e.input: @@ -201,13 +200,12 @@ func (e *Engine) Run(ctx context.Context) { dirtySnapshot = false } case now := <-checkpointTick.C: - if dirtyCheckpoint { - pruned, saved := e.flushCheckpointAndReset(now) - if pruned { - dirtySnapshot = false - } - dirtyCheckpoint = !saved + // Expiry is clock-driven even when no new MQTT state needs saving. + reset, saved := e.flushCheckpointAndReset(now, dirtyCheckpoint) + if reset { + dirtySnapshot = false } + dirtyCheckpoint = !saved } } } @@ -331,20 +329,20 @@ func (e *Engine) sourceNode(region string, packet meshcore.Packet) *privateNode return nil } matches := e.prefixes[prefixMapKey(region, 1, prefix)] - positioned := make([]*privateNode, 0, len(matches)) - for nodeKey := range matches { - if candidate := e.nodes[nodeKey]; candidate != nil && candidate.HasCoords { - positioned = append(positioned, candidate) - } - } - if len(positioned) != 1 { + // An unpositioned identity still makes a shared source hint ambiguous. + if len(matches) != 1 { return nil } - return positioned[0] + for nodeKey := range matches { + if candidate := e.nodes[nodeKey]; candidate != nil && candidate.HasCoords { + return candidate + } + } + return nil } func (e *Engine) resolveAndRecord(message mqtt.Message, packet meshcore.Packet, source, observer *privateNode, payloadKind string) ([]RouteSegmentV2, bool) { - if packet.InvalidForMap || (message.RSSI == nil && message.SNR == nil) { + if packet.InvalidForMap || (!finiteRF(message.RSSI) && !finiteRF(message.SNR)) { return nil, false } seen := make(map[string]struct{}, len(packet.Path)) @@ -476,6 +474,10 @@ func (e *Engine) emit(event Event) { } func (e *Engine) updateSnapshot(now time.Time) { + reset := e.snapshotResetPending + if reset { + e.seq.Add(1) + } nowMillis := now.UnixMilli() state := StateV2{ SchemaVersion: 2, @@ -523,6 +525,11 @@ func (e *Engine) updateSnapshot(now time.Time) { e.publicNodes.Store(int64(len(state.Nodes))) e.publicRoutes.Store(int64(len(state.Routes))) e.snapshot.Store(body) + if reset { + // Recovering clients must see the snapshot carrying this exact cursor. + e.snapshotResetPending = false + e.emit(Event{Name: "reset", Seq: state.Seq, Data: ResetEventV2{Seq: state.Seq, BootID: e.bootID}}) + } } func (e *Engine) publicStatus(now time.Time) PublicStatus { @@ -536,9 +543,14 @@ func (e *Engine) publicStatus(now time.Time) PublicStatus { return PublicStatus{Feed: feed, Activity: activity, LastPacketAt: e.lastPacket, Dropped: e.dropped.Load(), Version: e.version, GitSHA: e.gitSHA} } -func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { +func (e *Engine) flushCheckpoint(now time.Time, dirty bool) (bool, bool) { prunedRoutes, prunedNodes := e.pruneDurableState(now.UnixMilli()) pruned := prunedRoutes > 0 || prunedNodes > 0 + if !dirty && !pruned { + return false, true + } + e.prunedNodes.Add(int64(prunedNodes)) + e.prunedRoutes.Add(int64(prunedRoutes)) started := time.Now() if err := writeCheckpoint(e.checkpoint, e.nodes, e.routes); err != nil { e.checkpointOK.Store(false) @@ -556,8 +568,6 @@ func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { e.lastCheckpointAt.Store(now.UnixMilli()) e.lastCheckpointNodes.Store(int64(len(e.nodes))) e.lastCheckpointRoutes.Store(int64(len(e.routes))) - e.prunedNodes.Add(int64(prunedNodes)) - e.prunedRoutes.Add(int64(prunedRoutes)) e.log.Info("checkpoint saved", "bytes", bytes, "durationMs", duration.Milliseconds(), @@ -569,14 +579,13 @@ func (e *Engine) flushCheckpoint(now time.Time) (bool, bool) { return pruned, true } -func (e *Engine) flushCheckpointAndReset(now time.Time) (bool, bool) { - pruned, saved := e.flushCheckpoint(now) - if !pruned { +func (e *Engine) flushCheckpointAndReset(now time.Time, dirty bool) (bool, bool) { + pruned, saved := e.flushCheckpoint(now, dirty) + if !pruned && !e.snapshotResetPending { return false, saved } - seq := e.seq.Add(1) + e.snapshotResetPending = true e.updateSnapshot(now) - e.emit(Event{Name: "reset", Seq: seq, Data: ResetEventV2{Seq: seq, BootID: e.bootID}}) return true, saved } @@ -654,6 +663,7 @@ func (e *Engine) evictOldestNode() { return } id := nodePublicID(oldest) + e.snapshotResetPending = true delete(e.nodes, oldestKey) e.unindexNode(oldestKey, oldest) e.refreshNodeID(id) @@ -688,6 +698,7 @@ func (e *Engine) evictRoutes() { } } delete(e.routes, oldestID) + e.snapshotResetPending = true } } @@ -790,6 +801,10 @@ func isSensitiveHex(value string) bool { return false } +func finiteRF(value *float64) bool { + return value != nil && !math.IsNaN(*value) && !math.IsInf(*value, 0) +} + func validCoords(lat, lng float64) bool { return !math.IsNaN(lat) && !math.IsNaN(lng) && !math.IsInf(lat, 0) && !math.IsInf(lng, 0) && lat >= -85.051129 && lat <= 85.051129 && lng >= -180 && lng <= 180 && (lat != 0 || lng != 0) } diff --git a/backend/internal/engine/engine_test.go b/backend/internal/engine/engine_test.go index 2ca302a..77990ab 100644 --- a/backend/internal/engine/engine_test.go +++ b/backend/internal/engine/engine_test.go @@ -511,7 +511,7 @@ func TestCheckpointPruningResetsClientsAfterRefreshingSnapshot(t *testing.T) { } }) - pruned, saved := state.flushCheckpointAndReset(now) + pruned, saved := state.flushCheckpointAndReset(now, true) if !pruned || !saved { t.Fatalf("checkpoint pruned/saved = %v/%v, want true/true", pruned, saved) } diff --git a/backend/internal/engine/foundation_test.go b/backend/internal/engine/foundation_test.go new file mode 100644 index 0000000..608e0d2 --- /dev/null +++ b/backend/internal/engine/foundation_test.go @@ -0,0 +1,219 @@ +package engine + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "github.com/n30nex/cartolite-server/backend/internal/meshcore" + "github.com/n30nex/cartolite-server/backend/internal/mqtt" +) + +func TestUnpositionedSourceCollisionDoesNotCreateAnOriginLeg(t *testing.T) { + state := newTestEngine(t) + now := time.Now().UnixMilli() + source, _ := state.upsertNode("YKF", "DD112233", "Positioned", "companion", false, 43.35, -80.35, true, now) + state.upsertNode("YKF", "DD998877", "Unpositioned", "companion", false, 0, 0, false, now) + hop, _ := state.upsertNode("YKF", "AA112233", "Relay", "repeater", false, 43.4, -80.4, true, now) + observer, _ := state.upsertNode("YKF", "CC112233", "Observer", "unknown", true, 43.5, -80.5, true, now) + rssi := -75.0 + state.process(mqtt.Message{ + Topic: mqtt.Topic{Region: "YKF", PublisherKey: "CC112233", Kind: "packets"}, + ObserverKey: "CC112233", RSSI: &rssi, HeardAt: now, + RawHex: packetHexPayload(meshcore.PayloadPlainText, 1, []byte{0xaa}, 0, 0xdd), + }) + if len(state.routes) != 1 || state.routes[routePublicID(nodePublicID(hop), nodePublicID(observer))] == nil { + t.Fatal("independently resolved relay-to-observer hop was not preserved") + } + if state.routes[routePublicID(nodePublicID(source), nodePublicID(hop))] != nil { + t.Fatal("ambiguous source acquired a guessed origin leg") + } +} + +func TestResolverRequiresFiniteRFEvidence(t *testing.T) { + invalid, infinite, valid := math.NaN(), math.Inf(1), -75.0 + for _, test := range []struct { + name string + rssi *float64 + snr *float64 + want int + }{ + {"missing", nil, nil, 0}, + {"nan", &invalid, nil, 0}, + {"infinity", nil, &infinite, 0}, + {"both invalid", &invalid, &infinite, 0}, + {"RSSI only", &valid, nil, 1}, + {"finite SNR despite bad RSSI", &invalid, &valid, 1}, + } { + t.Run(test.name, func(t *testing.T) { + state := newTestEngine(t) + now := time.Now().UnixMilli() + state.upsertNode("YKF", "AA112233", "Relay", "repeater", false, 43.4, -80.4, true, now) + state.upsertNode("YKF", "CC112233", "Observer", "unknown", true, 43.5, -80.5, true, now) + state.process(mqtt.Message{ + Topic: mqtt.Topic{Region: "YKF", PublisherKey: "CC112233", Kind: "packets"}, + ObserverKey: "CC112233", RSSI: test.rssi, SNR: test.snr, HeardAt: now, + RawHex: packetHex(meshcore.PayloadControl, 1, 0xaa), + }) + if len(state.routes) != test.want { + t.Fatalf("got %d routes, want %d", len(state.routes), test.want) + } + }) + } +} + +// Observe the wire contract: the replacement snapshot must already be available +// when a client receives its reset cursor, with every route endpoint present. +func collectResetSnapshots(t *testing.T, state *Engine) *[]StateV2 { + t.Helper() + snapshots := []StateV2{} + state.SetPublisher(func(event Event) { + if event.Name != "reset" { + return + } + var snapshot StateV2 + if err := json.Unmarshal(state.StateJSON(), &snapshot); err != nil { + t.Fatal(err) + } + reset, ok := event.Data.(ResetEventV2) + if !ok || reset.Seq != event.Seq || snapshot.Seq != event.Seq || reset.BootID != snapshot.BootID { + t.Fatal("reset was published before its matching snapshot") + } + ids := make(map[string]bool, len(snapshot.Nodes)) + for _, node := range snapshot.Nodes { + ids[node.ID] = true + } + for _, route := range snapshot.Routes { + if !ids[route.FromID] || !ids[route.ToID] { + t.Fatal("reset snapshot has a dangling route") + } + } + snapshots = append(snapshots, snapshot) + }) + return &snapshots +} + +func TestNodeCapacityEvictionsCoalesceIntoOneAuthoritativeReset(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + for index := 0; index < maxNodes; index++ { + node := &privateNode{ + Region: "YKF", Key: fmt.Sprintf("%08X", index+1), Label: "Fixture", Role: "repeater", + HasCoords: true, Lat: 43.4, Lng: -80.4, LastSeen: now.Add(time.Duration(index) * time.Millisecond).UnixMilli(), + } + key := nodeMapKey(node.Region, node.Key) + state.nodes[key] = node + state.nodeIDs[nodePublicID(node)] = node + state.indexNode(key, node) + } + oldID := nodePublicID(state.nodes[nodeMapKey("YKF", "00000001")]) + nextID := nodePublicID(state.nodes[nodeMapKey("YKF", "00000002")]) + routeID := routePublicID(oldID, nextID) + state.routes[routeID] = &privateRoute{ID: routeID, FromID: oldID, ToID: nextID, PacketCount: 1, LastHeard: now.UnixMilli(), LastKind: "Text", Traffic: 1} + state.updateSnapshot(now) + resets := collectResetSnapshots(t, state) + for _, key := range []string{"FFFF0001", "FFFF0002"} { + state.upsertNode("YKF", key, "New fixture", "repeater", false, 43.5, -80.5, true, now.Add(time.Minute).UnixMilli()) + } + if len(*resets) != 0 { + t.Fatal("evictions reset clients per packet instead of on the snapshot clock") + } + state.updateSnapshot(now.Add(time.Minute)) + if len(*resets) != 1 || len((*resets)[0].Nodes) != maxNodes || len((*resets)[0].Routes) != 0 { + t.Fatal("node-capacity reset did not provide bounded current topology") + } + for _, node := range (*resets)[0].Nodes { + if node.ID == oldID || node.ID == nextID { + t.Fatal("evicted identity survived in replacement snapshot") + } + } + state.updateSnapshot(now.Add(2 * time.Minute)) + if len(*resets) != 1 { + t.Fatal("unchanged topology emitted another reset") + } +} + +func TestRouteCapacityEvictionsResetClientsBeforeAnyExpiry(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + nodes := make([]*privateNode, 202) + for index := range nodes { + nodes[index], _ = state.upsertNode("YKF", fmt.Sprintf("%08X", index+1), "Fixture", "repeater", false, 43.4, -80.4, true, now.UnixMilli()) + } + oldest := []string{} + for from := 0; from < len(nodes) && len(state.routes) < maxRoutes+2; from++ { + for to := from + 1; to < len(nodes) && len(state.routes) < maxRoutes+2; to++ { + fromID, toID := nodePublicID(nodes[from]), nodePublicID(nodes[to]) + id := routePublicID(fromID, toID) + if len(oldest) < 2 { + oldest = append(oldest, id) + } + state.routes[id] = &privateRoute{ + ID: id, FromID: fromID, ToID: toID, PacketCount: 1, LastKind: "Text", Traffic: 1, + LastHeard: now.Add(time.Duration(len(state.routes)) * time.Millisecond).UnixMilli(), + } + } + } + state.updateSnapshot(now) + resets := collectResetSnapshots(t, state) + state.evictRoutes() + state.updateSnapshot(now.Add(time.Minute)) + if len(*resets) != 1 || len((*resets)[0].Routes) != maxRoutes { + t.Fatal("route-capacity reset did not provide the bounded snapshot") + } + for _, id := range oldest { + if state.routes[id] != nil { + t.Fatal("oldest route was not evicted") + } + } +} + +func TestQuietMaintenanceExpiresStateWithoutRepeatedWrites(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + first, _ := state.upsertNode("YKF", "AA112233", "Quiet first", "repeater", false, 43.4, -80.4, true, now.Add(-nodeRetentionWindow+time.Second).UnixMilli()) + second, _ := state.upsertNode("YKF", "BB112233", "Quiet second", "repeater", false, 43.5, -80.5, true, now.Add(-nodeRetentionWindow+time.Second).UnixMilli()) + fromID, toID := nodePublicID(first), nodePublicID(second) + id := routePublicID(fromID, toID) + state.routes[id] = &privateRoute{ID: id, FromID: fromID, ToID: toID, PacketCount: 1, LastKind: "Text", Traffic: 1, LastHeard: now.Add(-routeVisibilityWindow + time.Second).UnixMilli()} + if _, saved := state.flushCheckpointAndReset(now, true); !saved { + t.Fatal("could not establish clean checkpoint") + } + resets := collectResetSnapshots(t, state) + later := now.Add(2 * time.Second) + if reset, saved := state.flushCheckpointAndReset(later, false); !reset || !saved { + t.Fatal("quiet maintenance skipped expired state") + } + nodes, routes, err := loadCheckpoint(state.checkpoint) + if err != nil || len(nodes) != 0 || len(routes) != 0 || len(*resets) != 1 { + t.Fatal("expired topology survived checkpoint or client reconciliation") + } + lastSave := state.OperationalStats().LastCheckpointAt + if reset, saved := state.flushCheckpointAndReset(later.Add(checkpointInterval), false); reset || !saved { + t.Fatal("unchanged quiet maintenance did not remain idle") + } + if state.OperationalStats().LastCheckpointAt != lastSave { + t.Fatal("clean quiet state caused an unnecessary checkpoint write") + } +} + +func TestFailedExpiryCheckpointStillResetsAndCanRetry(t *testing.T) { + state := newTestEngine(t) + now := time.Now() + state.upsertNode("YKF", "AA112233", "Expired", "repeater", false, 43.4, -80.4, true, now.Add(-nodeRetentionWindow-time.Second).UnixMilli()) + path := state.checkpoint + state.checkpoint = t.TempDir() // A directory cannot be replaced by the checkpoint file. + resets := collectResetSnapshots(t, state) + if reset, saved := state.flushCheckpointAndReset(now, false); !reset || saved || state.CheckpointHealthy() { + t.Fatal("failed expiry save hid the topology reset or checkpoint failure") + } + state.checkpoint = path + if reset, saved := state.flushCheckpointAndReset(now.Add(checkpointInterval), true); reset || !saved || !state.CheckpointHealthy() { + t.Fatal("failed checkpoint could not be retried without a duplicate reset") + } + if len(*resets) != 1 || state.OperationalStats().PrunedCheckpointNodes != 1 { + t.Fatal("pruning/reset was lost or counted twice across checkpoint retry") + } +} diff --git a/backend/internal/meshcore/decode.go b/backend/internal/meshcore/decode.go index e04e6b4..f56eb1a 100644 --- a/backend/internal/meshcore/decode.go +++ b/backend/internal/meshcore/decode.go @@ -160,7 +160,7 @@ func SourcePublicKey(packet Packet) string { } // SourcePrefix returns the one-byte source hint present in these normal packet -// payloads. Callers must require a unique positioned node before using it. +// payloads. Callers must establish unique identity before requiring coordinates. func SourcePrefix(packet Packet) (string, bool) { switch packet.PayloadType { case PayloadRequest, PayloadResponse, PayloadPlainText, PayloadPath: diff --git a/backend/internal/mqtt/message.go b/backend/internal/mqtt/message.go index ad5edd5..de7994a 100644 --- a/backend/internal/mqtt/message.go +++ b/backend/internal/mqtt/message.go @@ -4,12 +4,16 @@ import ( "bytes" "encoding/json" "fmt" + "math" "regexp" "strconv" "strings" "time" ) +// Bound bridge-envelope parsing and queued state; transport limits belong to the broker. +const maxMessageBytes = 64 * 1024 + var ( regionPattern = regexp.MustCompile(`^[A-Z0-9_-]{1,16}$`) keyPattern = regexp.MustCompile(`^[0-9A-F]{8,128}$`) @@ -53,6 +57,9 @@ func ParseTopic(value string) (Topic, error) { } func Normalize(topic string, body []byte, receivedAt time.Time) (Message, error) { + if len(body) > maxMessageBytes { + return Message{}, fmt.Errorf("mqtt payload exceeds %d bytes", maxMessageBytes) + } info, err := ParseTopic(topic) if err != nil { return Message{}, err @@ -140,16 +147,21 @@ func firstString(values map[string]any, keys ...string) string { func firstNumber(values map[string]any, keys ...string) *float64 { for _, key := range keys { - if value, ok := values[key]; ok { - switch typed := value.(type) { - case float64: - copy := typed - return © - case string: - if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil { - return &parsed - } + var number float64 + switch typed := values[key].(type) { + case float64: + number = typed + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + continue } + number = parsed + default: + continue + } + if !math.IsNaN(number) && !math.IsInf(number, 0) { + return &number } } return nil diff --git a/backend/internal/mqtt/validation_test.go b/backend/internal/mqtt/validation_test.go new file mode 100644 index 0000000..b768b42 --- /dev/null +++ b/backend/internal/mqtt/validation_test.go @@ -0,0 +1,45 @@ +package mqtt + +import ( + "math" + "strings" + "testing" + "time" +) + +func TestNonFiniteNumbersDoNotBecomeRFOrCoordinates(t *testing.T) { + for _, invalid := range []any{"NaN", "+Inf", "-Inf", "Infinity", math.NaN(), math.Inf(1), math.Inf(-1)} { + if firstNumber(map[string]any{"rssi": invalid}, "rssi") != nil { + t.Fatalf("accepted non-finite numeric input %v", invalid) + } + if _, _, ok := Coordinates(map[string]any{"lat": invalid, "lng": -80.0}); ok { + t.Fatal("accepted a non-finite coordinate") + } + } + value := firstNumber(map[string]any{"RSSI": "NaN", "rssi": "-75"}, "RSSI", "rssi") + if value == nil || *value != -75 { + t.Fatal("a finite fallback field was ignored") + } + message, err := Normalize("meshcore/YKF/AA112233/packets", []byte(`{"raw":"0901AA00AA","rssi":"NaN","snr":"7.4"}`), time.Now()) + if err != nil || message.RSSI != nil || message.SNR == nil || *message.SNR != 7.4 { + t.Fatal("normalization did not retain only finite RF evidence") + } +} + +func TestMQTTEnvelopeSizeIsBoundedBeforeNormalization(t *testing.T) { + topic := "meshcore/YKF/AA112233/packets" + for _, oversized := range []string{ + strings.Repeat("AA", maxMessageBytes/2+1), + `{"raw":"` + strings.Repeat("AA", maxMessageBytes/2) + `"}`, + } { + if _, err := Normalize(topic, []byte(oversized), time.Now()); err == nil { + t.Fatal("oversized MQTT payload reached normalization") + } + } + valid := `{"raw":"0901AA00AA","rssi":-75}` + body := valid + strings.Repeat(" ", maxMessageBytes-len(valid)) + message, err := Normalize(topic, []byte(body), time.Now()) + if err != nil || message.RawHex != "0901AA00AA" || message.RSSI == nil { + t.Fatal("valid envelope at the byte limit was rejected") + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..3fd0914 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,89 @@ +# Audit roadmap + +Updated: 9 September 2026. This roadmap turns the end-to-end audit into tracked delivery work. Audit IDs A01–A12 are retained; F IDs track features and R IDs track validation risks. + +This is the worldwide source-release roadmap. Shared work is paired with [CartoLite Canada](https://github.com/n30nex/CartoLite); A04/O1 is tracked upstream and is not an operator rollout for this repository. + +## Status and completion rules + +- **Planned:** accepted direction, implementation has not started. +- **Active:** current work package. +- **Implemented:** code and regression coverage are present; merge, release and runtime evidence are tracked separately below. +- **Review:** implementation and regression checks are in a pull request; consult its exact-commit Actions checks. +- **Done:** merged with passing checks. Release/deployment is recorded separately. +- **Needs setup:** an external configuration, provider choice, or physical test is required. + +Each item needs its acceptance check, edition parity, and an exact CI result before it becomes Done. Code completion does not imply a production rollout. All build/test execution for roadmap work uses GitHub Actions. Fixtures must be synthetic. + +## Milestones + +| Milestone | Status | Deliverable / exit condition | +|---|---|---| +| M1 — Security and data correctness | Implemented | A01, A02, A03, A05 and A06 are implemented in the paired corrective release. Merge/release requires the existing full CI plus focused regressions in both editions. | +| O1 — Canada recovery operations | Needs setup | A04 and R09; independently verifiable backup, isolated restore, monitoring, and current deployment identity. This is a separate Canada operations package. | +| M2 — Usability and portable operation | Planned | A07–A12; honest count/window labels, optional storage, keyboard continuity, readable mobile counters, dependency/release policy, and identifiable worldwide installs. | +| M3 — Follow and display refinement | Planned | F01–F08; area-constrained follow, readable cards, shared display/motion controls, layer strengths, camera controls, cross-view selection and bounded discovery/focus. | +| M4 — Optional additions | Planned | F09–F12; bookmarks, ground elevation context, configured tile types and localization/exhibition polish. Provider-dependent work follows A01 and R08. | +| V1 — Resilience and scale evidence | Planned | R01–R09 exercised where their related code changes; improve measured bottlenecks and preserve privacy, rendering and runtime budgets. | + +## Corrective backlog + +| ID | Priority | Scope | Status | Work and acceptance | +|---|---|---|---|---| +| A01 | P1 | Both | Implemented | Upgrade MapLibre 5.19.0 to patched 6.4.1, adapt ESM imports and bundled worker setup, and scan runtime dependencies from the lockfile. Exact-revision type/build, map, terrain, route-layer, recovery and browser gates pass. Track WebGL2 as the map minimum. | +| A02 | P1 | Both | Implemented | Check source-prefix uniqueness before checking coordinates. A positioned/unpositioned collision never creates a guessed origin leg; independently resolved downstream hops remain visible. | +| A03 | P1 | Both | Implemented | Coalesce capacity-eviction resets after an authoritative snapshot. Crossing node/route caps leaves the browser consistent and bounded, with no dangling endpoint references or reset per packet. | +| A04 | P1 | Canada | Needs setup | Configure the documented encrypted off-host backup and restore path. Prove a checksummed isolated restore with the matching image/version and independent monitoring; preserve production throughout validation. | +| A05 | P2 | Both | Implemented | Reject non-finite RF/coordinate values and impose a bounded MQTT envelope before normalization. Synthetic invalid numbers and over-limit JSON/hex are rejected; valid RSSI-only and SNR-only packets still work. | +| A06 | P2 | Both | Implemented | Run retention maintenance during quiet periods and avoid unnecessary checkpoint writes. With no new messages, expired routes/nodes leave the API, checkpoint and connected client; quiet connected feeds remain ready. | +| A07 | P2 | Both | Planned | Label cumulative observation totals honestly and describe windows as “links heard within this period.” Never imply current totals are unique packets counted within the chosen interval. | +| A08 | P2 | Both map pages | Planned | Guard access to browser storage itself and fall back to in-memory preferences. A throwing storage getter cannot prevent startup or control use. | +| A09 | P2 | Both | Planned | Transfer keyboard selection focus to details, restore it on close, add Netgraph keyboard pan/zoom/reset, and bounded node/area browsing. Find → details → neighbour → close works without a pointer. | +| A10 | P2 | Both Netgraphs | Planned | Give every phone counter a visible or accessible name; prefer a concise nodes/links summary with optional area/component detail. Validate at 320–480 px without overlap. | +| A11 | P2 | Both delivery processes | Planned | Make dependency contributions testable without production credentials, retain trusted provider verification, enforce worldwide exact-revision merge/release checks and enable dependency alerting. M1 covers the runtime lockfile scan and automatic publication of the verified manual-run status on the exact commit; repository enforcement and alerts remain planned. | +| A12 | P2 | Worldwide | Planned | Improve setup identity, require an instance-specific MQTT client ID, and refresh upgrade/rollback instructions. A documented install reports its actual revision/version; independent instances have distinct identities. | + +P1 means next corrective work; P2 means the next refinement cycle. [MapLibre advisory GHSA-jrc7-96c5-q579](https://github.com/maplibre/maplibre-gl-js/security/advisories/GHSA-jrc7-96c5-q579) affects the audited dependency. The audit established the vulnerable version, not exploitation of this application. + +## Feature backlog + +| ID | Milestone | Status | Feature and acceptance | +|---|---|---|---| +| F01 | M3 | Planned | **Follow scope:** Everywhere / This area / Selected node, plus confirmed-routes-only. Keep ten seconds as the default; a locked area never expands just because the camera moves. Expose existing selected-node filtering clearly. | +| F02 | M3 | Planned | **Follow card:** Hold, Next and Inspect, observation age, and a distinct observer-only state. Hold/pause stop an active camera ease; Inspect opens existing details and pauses the director. | +| F03 | M3 | Planned | **Shared Display menu:** interface theme, motion, text size and contrast across Map/Netgraph; Calm / Standard / Showcase presets and recoverable custom settings. Preserve OS reduced-motion preferences. | +| F04 | M3 | Planned | **Independent effect strength:** historical lines, packet glow, residue duration and heat strength. Reducing decoration keeps confirmed links available and sharp at distant zooms. | +| F05 | M3 | Planned | **Terrain/camera controls:** compass/reset north, scale, optional pitch, clearer shading vs 3D-height terminology, and zoom-aware terrain contrast. Labels and packet cores remain readable. | +| F06 | M3 | Planned | **Map ↔ Netgraph continuity:** open the selected public node in the other view; preserve useful context and explain nodes absent from that route window. | +| F07 | M3 | Planned | **Local discovery:** role, recently-seen and current-view filters with bounded empty-query browsing; worldwide grid filtering. Filters use downloaded public data and have an obvious reset. | +| F08 | M3 | Planned | **Netgraph focus:** isolate an area/component, mute unrelated decoration, and reduce competing traffic badges. Keep an obvious all-areas reset and preserve all underlying links. | +| F09 | M4 | Planned | **Local bookmarks / shareable views:** named centre/zoom/bearing/pitch/display presets. Shared URLs contain public view settings only, never private configuration or transient packet identifiers. | +| F10 | M4 | Planned | **Ground elevation context:** on-demand approximate ground height and endpoint height difference, optionally a selected-route profile. Remove visual exaggeration from numeric values and handle missing DEM data. Never present it as antenna height or RF coverage. | +| F11 | M4 | Needs setup | **Actual tile types:** operator-configured satellite/hybrid or another approved provider, after A01. Require explicit attribution, credential handling, allowed hosts, contrast and failure fallback. No provider entitlement is assumed. | +| F12 | M4 | Planned | **Locale / exhibition polish:** English/French Canada chrome, extensible worldwide translations, native locale formatting and selectable units; exhibition and explicit keep-awake preferences using existing patterns. | + +M3 uses existing public data and browser-local settings. M4 terrain features use the existing DEM on demand; additional tile providers require configuration. Route coverage, antenna height, raw RF details and historical interval totals cannot be inferred from the present public state. + +## Evidence and engineering backlog + +| ID | Status | Validation / improvement | +|---|---|---| +| R01 — Date-line follow | Planned | Test both directions across 179°/−179° in pitched/rotated Follow at detail zoom, desktop and phone. Existing overview seam coverage does not prove this combination. | +| R02 — Time and replay | Planned | Separate receipt freshness from RF observation age; test out-of-order/future timestamps, retained MQTT messages and browser clock skew. Old/replayed observations must not masquerade as new activity. | +| R03 — Backend hot path | Planned | Profile sustained ingest at 10k nodes / 20k routes. Reduce whole-node scans in public-ID refresh only with measured evidence and unchanged identity semantics. | +| R04 — Recovery pressure | Planned | Test concurrent resync, stream capacity refusal and slow state readers; add bounded response work and retry jitter where justified. | +| R05 — Live event validation | Planned | Validate event shape and sequence before store mutation, reuse existing validators, and recover once on a malformed stream. | +| R06 — Budget / parity | Planned | Track page-entry and lazy assets alongside total bytes; extract cohesive helpers as touched. Record intentional Canada/worldwide differences and check shared fixes for drift. | +| R07 — Browser / device coverage | Planned | Add focused Firefox/WebKit evidence where feasible and physical Android GPU, sleep, audio and thermal checks for Canada. Browser emulation is not hardware validation. | +| R08 — Degraded services | Planned | Exercise basemap-key failure, DEM timeout, region-asset failure and missing WebGL2 independently. Retain useful status/data and explain unavailable features concisely. | +| R09 — Operational policy | Needs setup | Verify independent monitoring and off-host restore; document the response to lifetime drop alarms and distinguish present pressure from past drops without silently clearing the alarm. | + +## Delivery and parity record + +The audited baselines were Canada 0.12.0 (`2e827a4`) and Worldwide 0.3.1 (`6a446ee`). M1 is delivered through [Canada PR #70](https://github.com/n30nex/CartoLite/pull/70) and [Worldwide PR #9](https://github.com/n30nex/CartoLite-Server/pull/9), targeting releases [0.12.1](https://github.com/n30nex/CartoLite/releases/tag/v0.12.1) and [0.3.2](https://github.com/n30nex/CartoLite-Server/releases/tag/v0.3.2). Use those PR checks and release manifests for exact revisions and validation results. + +The migration measured 474,482 compressed JS/CSS bytes for Canada and 446,084 for Worldwide in Actions, including the new 129,701-byte map worker. M1 now enforces total, non-map-worker, and map-worker caps separately. R06 still tracks entry-page/lazy-load measurement. Rendering timing and topology-count gates are unchanged. + +For each work package, record its PR, exact checked commit, CI result, counterpart PR or scope exception, and any release/runtime proof. A green candidate PR remains Review until merged; a merged change remains undeployed until an exact release is rolled out and verified. + +Preserve public schema v2, conservative route resolution, the single-service/atomic-checkpoint architecture and hardened runtime. Canada keeps its published-image promotion path, geographic scope, Labs and Android. Worldwide remains an independent source distribution with coordinate-derived areas, optional exact region filtering, and no Canada-only data, Labs or Android. diff --git a/scripts/check-frontend-budget.mjs b/scripts/check-frontend-budget.mjs index d3c48f4..8b47b40 100644 --- a/scripts/check-frontend-budget.mjs +++ b/scripts/check-frontend-budget.mjs @@ -1,9 +1,14 @@ import { gzipSync } from "node:zlib"; import { readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; const root = process.argv[2] ?? "web/dist"; -const limit = Number(process.env.CARTOLITE_GZIP_BUDGET ?? 350 * 1024); +// MapLibre 6 requires a separate Vite worker (129,701 gzip bytes in Actions). +// Keep the previous cap for all other JS/CSS; account for the worker explicitly. +// Combined measured cost: 446,084 bytes. Runtime timing/route gates are unchanged. +const uiLimit = 350 * 1024; +const workerLimit = 128 * 1024; +const limit = Number(process.env.CARTOLITE_GZIP_BUDGET ?? (uiLimit + workerLimit)); async function assets(dir, pattern) { const found = []; @@ -15,15 +20,25 @@ async function assets(dir, pattern) { return found; } -const files = await assets(root, /\.(?:css|js)$/); +const files = await assets(root, /\.(?:css|m?js)$/); if (files.length === 0) throw new Error(`no JavaScript or CSS assets found in ${root}`); let total = 0; +let workerBytes = 0; +let workerCount = 0; for (const file of files) { const bytes = gzipSync(await readFile(file), { level: 9 }).byteLength; total += bytes; + if (/^maplibre-gl-worker-.*\.m?js$/.test(basename(file))) { + workerBytes += bytes; + workerCount += 1; + } console.log(`${bytes.toString().padStart(8)} ${file}`); } console.log(`${total} gzip bytes total (budget ${limit})`); -if (total > limit) process.exitCode = 1; +console.log(`${total - workerBytes} gzip bytes excluding map worker (budget ${uiLimit})`); +console.log(`${workerBytes} gzip bytes for map worker (budget ${workerLimit})`); +if (workerCount !== 1) throw new Error(`expected one map worker, found ${workerCount}`); +if (!Number.isFinite(limit) || limit <= 0) throw new Error('invalid total frontend budget'); +if (total > limit || total - workerBytes > uiLimit || workerBytes > workerLimit) process.exitCode = 1; diff --git a/web/package-lock.json b/web/package-lock.json index fdf0cd8..e079349 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,14 +1,14 @@ { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { - "maplibre-gl": "5.19.0" + "maplibre-gl": "6.4.1" }, "devDependencies": { "@playwright/test": "1.61.1", @@ -192,19 +192,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", - "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "license": "ISC", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", @@ -227,44 +214,38 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", - "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", - "pbf": "^4.0.2" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" + "pbf": "^5.0.0" } }, "node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", - "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", - "license": "ISC" - }, - "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", - "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", "license": "ISC", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz", + "integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", @@ -277,12 +258,6 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", - "license": "BSD-2-Clause" - }, "node_modules/@maplibre/mlt": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", @@ -303,18 +278,6 @@ "pbf": "^5.1.0" } }, - "node_modules/@maplibre/vt-pbf/node_modules/pbf": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", - "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -673,15 +636,6 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1155,18 +1109,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", @@ -1621,32 +1563,27 @@ } }, "node_modules/maplibre-gl": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", - "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz", + "integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.0.7", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^5.0.4", - "@maplibre/maplibre-gl-style-spec": "^24.4.1", - "@maplibre/mlt": "^1.1.6", - "@maplibre/vt-pbf": "^4.2.1", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", - "earcut": "^3.0.2", + "earcut": "^3.2.3", "gl-matrix": "^3.4.4", - "kdbush": "^4.0.2", + "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", + "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", - "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" }, "engines": { @@ -1773,9 +1710,9 @@ "license": "MIT" }, "node_modules/pbf": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", - "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", "license": "BSD-3-Clause", "dependencies": { "resolve-protobuf-schema": "^2.1.0" @@ -1994,15 +1931,6 @@ "dev": true, "license": "MIT" }, - "node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/web/package.json b/web/package.json index 3988296..c2f7528 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.3.1", + "version": "0.3.2", "private": true, "type": "module", "scripts": { @@ -11,7 +11,7 @@ "test:watch": "vitest" }, "dependencies": { - "maplibre-gl": "5.19.0" + "maplibre-gl": "6.4.1" }, "devDependencies": { "@playwright/test": "1.61.1", diff --git a/web/src/attribution.test.ts b/web/src/attribution.test.ts new file mode 100644 index 0000000..1f9f4e1 --- /dev/null +++ b/web/src/attribution.test.ts @@ -0,0 +1,22 @@ +import { AttributionControl, type Map as MapLibreMap } from 'maplibre-gl'; +import { expect, it } from 'vitest'; + +it('removes consecutive unsafe attribution attributes while keeping safe credit text and links', () => { + // Exercise the dependency's public control API without requiring a GPU in the unit job. + const map = { + style: { tileManagers: {} }, + _getUIString: (key: string) => key, + getCanvasContainer: () => document.createElement('div'), + on() {}, + off() {}, + } as unknown as MapLibreMap; + const control = new AttributionControl({ + compact: false, + customAttribution: '
Fixture attribution
Credits', + }); + const element = control.onAdd(map); + expect(element.textContent).toContain('Fixture attribution'); + expect(element.querySelector('[onload], [ontoggle]')).toBeNull(); + expect(element.querySelector('a')?.getAttribute('href')).toBe('https://example.invalid'); + control.onRemove(); +}); diff --git a/web/src/map.ts b/web/src/map.ts index ec4de83..6c79ce9 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -1,4 +1,6 @@ -import maplibregl, { +import * as maplibregl from 'maplibre-gl'; +import workerURL from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; +import { type ExpressionSpecification, type GeoJSONSource, type GeoJSONSourceDiff, @@ -36,6 +38,8 @@ import { } from './trafficVisuals'; import type { EndpointV2, NodeV2, PacketView, RouteV2, StateV2 } from './types'; +maplibregl.setWorkerUrl(workerURL); + export const DEFAULT_CENTER: [number, number] = [0, 20]; export const DEFAULT_ZOOM = 1.4; export const DETAIL_ZOOM = 8.4; @@ -682,7 +686,9 @@ export class LiveMap { if (force || previous.basemap !== preferences.basemap) { for (const layer of cartoVectorStyle(undefined, preferences.basemap).layers) { if (!this.map.getLayer(layer.id) || !('paint' in layer)) continue; - for (const [property, value] of Object.entries(layer.paint ?? {})) this.map.setPaintProperty(layer.id, property, value); + for (const [property, value] of Object.entries(layer.paint ?? {})) { + this.map.setPaintProperty(layer.id, property as Parameters[1], value); + } } const light = preferences.basemap !== 'dark'; this.historicalRouteLayer.setLightBackground(light); diff --git a/web/src/packetAnimator.test.ts b/web/src/packetAnimator.test.ts index c77aaa0..b7a7950 100644 --- a/web/src/packetAnimator.test.ts +++ b/web/src/packetAnimator.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type maplibregl from 'maplibre-gl'; +import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, RoutePacketView, RouteSegmentView } from './types'; import { PACKET_KIND_COLORS } from './trafficVisuals'; import { diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index bc425be..9102787 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -1,5 +1,5 @@ import { stableHash as stableVisualHash, colorWithAlpha as withAlpha } from './trafficVisuals'; -import type maplibregl from 'maplibre-gl'; +import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, ObserverPacketEventV2, PacketView, RoutePacketView, RouteSegmentView } from './types'; import { TerrainProjector, surfaceArc, surfacePathPoint, surfaceTrail, traceSurfacePath, type SurfacePoint } from './terrainProjection'; import {