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 {