From 9e67c08c5f9fc0c93cef9ecd42fb25a244aac691 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 08:25:32 +0000 Subject: [PATCH 1/3] Map: resolve fflate, image-size and minimatch advisories with npm overrides Dependabot's security updates fail with security_update_not_possible because the parents pin these transitive packages. Force the patched versions: fflate 0.7.5, image-size 2.1.0 and minimatch 11.0.8. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01PS44Q7WRmAzMJLhi44dzdo --- map/package-lock.json | 49 +++++++++---------------------------------- map/package.json | 10 +++++++++ 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/map/package-lock.json b/map/package-lock.json index f8f2f94..75c8b54 100644 --- a/map/package-lock.json +++ b/map/package-lock.json @@ -38,6 +38,9 @@ "shadcn": "^4.23.0", "tailwindcss": "^4.3.5", "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@alloc/quick-lru": { @@ -3312,22 +3315,6 @@ "node": ">= 6" } }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@turf/boolean-clockwise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", @@ -3671,22 +3658,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.6", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", @@ -6209,9 +6180,9 @@ } }, "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "license": "MIT" }, "node_modules/figures": { @@ -6757,15 +6728,15 @@ } }, "node_modules/image-size": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.4.tgz", - "integrity": "sha512-GqPgxs+VkOr12aWwjSkyRzf5atzObWpFtiRuDgxCl2I/SDpZOKZFRD3iIAeAN6/usmn8SeLWRt7a8JRYK0Whbw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.1.0.tgz", + "integrity": "sha512-6Dbisa46RsgeAh3TMSFL2i8+uIFG+/LRmz6Q2oscSJJvXo1sOw3EgNO4M8jPl1P+gR1atJLDPrpclX4IHz63sA==", "license": "MIT", "bin": { "image-size": "bin/image-size.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, "node_modules/immediate": { diff --git a/map/package.json b/map/package.json index d5a62ac..f7345e8 100644 --- a/map/package.json +++ b/map/package.json @@ -44,5 +44,15 @@ }, "engines": { "node": ">=22" + }, + "overrides": { + "fflate": "^0.7.5", + "image-size": "^2.1.0", + "@typescript-eslint/typescript-estree": { + "minimatch": "^11.0.8" + }, + "@ts-morph/common": { + "minimatch": "^11.0.8" + } } } From 5bcb2aa75c5c6dd072322204309df36b724bff89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 08:27:14 +0000 Subject: [PATCH 2/3] Service: keep positions from stations with no known location, fix station double count When a station had no coordinates, range was measured from (0,0), so the plausibility check rejected every message that carried a position. Only measure distance and bearing when the station is located, and still index the target cell otherwise. getStationMessageCounts added the requested window's cells on top of the count already taken from a shorter window, inflating station message counts. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01PS44Q7WRmAzMJLhi44dzdo --- service/src/aggregation/coverage-store.ts | 44 ++++++++----- service/test/ingest.test.ts | 80 +++++++++++++++++++++++ 2 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 service/test/ingest.test.ts diff --git a/service/src/aggregation/coverage-store.ts b/service/src/aggregation/coverage-store.ts index f56dd0a..e521318 100644 --- a/service/src/aggregation/coverage-store.ts +++ b/service/src/aggregation/coverage-store.ts @@ -96,6 +96,9 @@ export class CoverageStore { } } + const stationLocated = Number.isFinite(stationLat) && Number.isFinite(stationLon) + && !(Math.abs(stationLat) < 0.01 && Math.abs(stationLon) < 0.01); + // 4. Validate target coordinates if present let distance = 0; let brng = 0; @@ -104,8 +107,12 @@ export class CoverageStore { if (event.hasTargetPosition && event.targetLat !== null && event.targetLon !== null) { const targetCheck = validateCoordinate(event.targetLat, event.targetLon); if (targetCheck.valid) { - distance = haversineDistance(stationLat, stationLon, event.targetLat, event.targetLon); - brng = bearing(stationLat, stationLon, event.targetLat, event.targetLon); + // Distance and bearing only mean something when we know where the station is. + // Measuring from (0,0) would fail the range check below and drop the event. + if (stationLocated) { + distance = haversineDistance(stationLat, stationLon, event.targetLat, event.targetLon); + brng = bearing(stationLat, stationLon, event.targetLat, event.targetLon); + } // Signal validation includes range plausibility const sigCheck = validateSignal( @@ -175,8 +182,9 @@ export class CoverageStore { } } - // 6. Update per-station directional coverage - this.updateStationCoverage(event, stationLat, stationLon, distance, brng, hasValidTarget); + // 6. Update per-station directional coverage. + // Without a station location there is no sector or range to record. + this.updateStationCoverage(event, stationLat, stationLon, distance, brng, hasValidTarget && stationLocated); } private updateStationCoverage( @@ -294,25 +302,31 @@ export class CoverageStore { ? Array.from({ length: wi + 1 }, (_, i) => i) : []; + const countedInRequested = new Set(); + for (const checkWi of windowsToCheck) { if (ri === -1) continue; const window = this.windows[ri][checkWi]; const aggregated = window.getAggregated(); for (const [, cell] of aggregated) { for (const stationId of cell.stationIds) { - const existing = stationCounts.get(stationId); - if (existing) { - // Use the max values (longer window should have more data) - if (checkWi === wi) { - existing.messageCount += cell.messageCount; + let existing = stationCounts.get(stationId); + if (!existing) { + existing = { messageCount: 0, maxDistance: 0 }; + stationCounts.set(stationId, existing); + } + if (checkWi === wi) { + // The requested window is authoritative: replace whatever a shorter + // window contributed instead of adding to it (that double counted). + if (!countedInRequested.has(stationId)) { + countedInRequested.add(stationId); + existing.messageCount = 0; } - existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance); - } else { - stationCounts.set(stationId, { - messageCount: cell.messageCount, - maxDistance: cell.maxDistance, - }); + existing.messageCount += cell.messageCount; + } else if (!countedInRequested.has(stationId)) { + existing.messageCount += cell.messageCount; } + existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance); } } } diff --git a/service/test/ingest.test.ts b/service/test/ingest.test.ts new file mode 100644 index 0000000..61c6424 --- /dev/null +++ b/service/test/ingest.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { CoverageStore } from '../src/aggregation/coverage-store.js'; +import type { CoverageEvent, StationMeta } from '../src/types/events.js'; + +function station(id: number, latitude: number, longitude: number): StationMeta { + return { + id, + uuid: `uuid-${id}`, + ident: `TEST-${id}`, + latitude, + longitude, + sourceType: 'vdl', + status: 'active', + flagged: false, + blocked: false, + trustScore: 0.5, + lastSyncedAt: new Date(), + }; +} + +function event(stationId: number, targetLat: number | null, targetLon: number | null): CoverageEvent { + return { + stationId, + stationUuid: `uuid-${stationId}`, + targetLat, + targetLon, + targetAlt: null, + transportType: 'aircraft', + sourceType: 'dumpvdl2', + source: 'dumpvdl2', + frequency: 136.975, + signalLevel: -20, + errorCount: 0, + timestamp: new Date(), + hasTargetPosition: targetLat !== null && targetLon !== null, + }; +} + +let store: CoverageStore; +afterEach(() => store.destroy()); + +describe('ingest', () => { + it('indexes a target position for a station with no known location', () => { + store = new CoverageStore(); + store.stationMap.set(1, station(1, 0, 0)); + + // Frankfurt. Measured from (0,0) this is ~5,600 km, far beyond VDL2 range. + store.ingest(event(1, 50.03, 8.57)); + + expect(store.eventsRejected).toBe(0); + expect(store.eventsProcessed).toBe(1); + expect(store.eventsWithPosition).toBe(1); + + const counts = store.getStationMessageCounts('1h').get(1); + expect(counts?.messageCount).toBe(1); + expect(counts?.maxDistance).toBe(0); + }); + + it('still rejects an implausible range when the station location is known', () => { + store = new CoverageStore(); + store.stationMap.set(2, station(2, 50.03, 8.57)); + + // New York is not reachable on VDL2 from Frankfurt. + store.ingest(event(2, 40.64, -73.78)); + + expect(store.eventsRejected).toBe(1); + expect(store.eventsProcessed).toBe(0); + }); + + it('records range for a located station and a nearby target', () => { + store = new CoverageStore(); + store.stationMap.set(3, station(3, 50.03, 8.57)); + + store.ingest(event(3, 50.9, 9.5)); + + expect(store.eventsProcessed).toBe(1); + const counts = store.getStationMessageCounts('1h').get(3); + expect(counts?.maxDistance).toBeGreaterThan(50); + }); +}); From b953f76ac97857265b7f0c08ee3b0099b8ab245b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 08:35:12 +0000 Subject: [PATCH 3/3] Service: never add station counts from overlapping windows Sum each window's cells separately; the requested window is authoritative and a station seen only in shorter windows keeps the largest single-window count. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01PS44Q7WRmAzMJLhi44dzdo --- service/src/aggregation/coverage-store.ts | 35 +++++++++++------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/service/src/aggregation/coverage-store.ts b/service/src/aggregation/coverage-store.ts index e521318..b6e9826 100644 --- a/service/src/aggregation/coverage-store.ts +++ b/service/src/aggregation/coverage-store.ts @@ -302,33 +302,32 @@ export class CoverageStore { ? Array.from({ length: wi + 1 }, (_, i) => i) : []; - const countedInRequested = new Set(); - for (const checkWi of windowsToCheck) { if (ri === -1) continue; const window = this.windows[ri][checkWi]; const aggregated = window.getAggregated(); + + // Sum this window's cells per station on their own. Windows overlap, so + // totals from different windows must never be added together. + const windowCounts = new Map(); for (const [, cell] of aggregated) { for (const stationId of cell.stationIds) { - let existing = stationCounts.get(stationId); - if (!existing) { - existing = { messageCount: 0, maxDistance: 0 }; - stationCounts.set(stationId, existing); - } - if (checkWi === wi) { - // The requested window is authoritative: replace whatever a shorter - // window contributed instead of adding to it (that double counted). - if (!countedInRequested.has(stationId)) { - countedInRequested.add(stationId); - existing.messageCount = 0; - } - existing.messageCount += cell.messageCount; - } else if (!countedInRequested.has(stationId)) { - existing.messageCount += cell.messageCount; + windowCounts.set(stationId, (windowCounts.get(stationId) ?? 0) + cell.messageCount); + const existing = stationCounts.get(stationId); + if (existing) { + existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance); + } else { + stationCounts.set(stationId, { messageCount: 0, maxDistance: cell.maxDistance }); } - existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance); } } + + for (const [stationId, count] of windowCounts) { + const existing = stationCounts.get(stationId)!; + // The requested window is authoritative. A station seen only in shorter + // windows keeps the largest single-window count. + existing.messageCount = checkWi === wi ? count : Math.max(existing.messageCount, count); + } } // Also check station sliding windows (for persistence-loaded data)