From c390724bc0dcf6f0f15112c0dcf6e5bb038350a8 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:46:01 -0400 Subject: [PATCH 1/2] fix(earthquakes): validate snapshots before replacing entities --- CHANGELOG.md | 4 +++ docs/CURRENT-STATE.md | 4 +++ src/data/earthquakes.js | 51 +++++++++++++++++++++++++---------- src/data/earthquakes.test.mjs | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa46..fc58e8fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861f4..bfd9683ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 18767adb2..476b72c18 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -121,6 +121,34 @@ export function mapAnalystRecord(raw, index = 0) { }; } +/** Validate a complete feed before replacing the last good earthquake snapshot. */ +export function normalizeEarthquakeSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const [index, feature] of geojson.features.entries()) { + const coordinates = feature?.geometry?.coordinates; + const properties = feature?.properties; + if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties + || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null; + const [lon, lat, depthKm] = coordinates; + const mag = properties.mag; + if (!Number.isFinite(lon) || Math.abs(lon) > 180 + || !Number.isFinite(lat) || Math.abs(lat) > 90 + || (depthKm != null && !Number.isFinite(depthKm)) + || (mag != null && (!Number.isFinite(mag) || mag > 10))) return null; + // A missing magnitude cannot establish that this event meets M2.5+. + if (mag == null || mag < 2.5) continue; + const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id); + if (ids.has(stableId)) return null; + ids.add(stableId); + rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null, + mag, place: typeof properties.place === 'string' ? properties.place : null, + time: Number.isFinite(properties.time) ? properties.time : null }); + } + return rows; +} + export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) { let _dataSource = null; let _count = 0; @@ -172,23 +200,17 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = } const geojson = await response.json(); - if (!geojson || !Array.isArray(geojson.features)) { + const rows = normalizeEarthquakeSnapshot(geojson); + if (!rows) { _lastError = 'Malformed USGS response'; return false; } - _dataSource.entities.removeAll(); + const nextEntities = []; let count = 0; const overlayEntries = []; - for (const feature of geojson.features) { - const [lon, lat, depthKm] = feature.geometry.coordinates; - const mag = feature.properties.mag; - const place = feature.properties.place; - const time = feature.properties.time; - - if (mag < 2.5) continue; // Skip micro-quakes - + for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) { count++; const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); @@ -197,8 +219,7 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = const outlineAlpha = isSignificant ? 1.0 : 0.8; const position = Cesium.Cartesian3.fromDegrees(lon, lat); - const stableId = feature.id || `event-${count}`; - _dataSource.entities.add({ + nextEntities.push(new Cesium.Entity({ id: `earthquake:${stableId}`, position, ellipse: { @@ -216,13 +237,13 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = }, properties: { // Analyst seam (additive): the USGS event id (e.g. "us7000abcd"). - usgsId: feature.id ?? null, + usgsId, mag, place, time, depth: depthKm, }, - }); + })); overlayEntries.push(createEarthquakeOverlayEntry({ id: String(stableId), position, @@ -231,6 +252,8 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = })); } + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); if (_enabled) { overlayHost.setEntries( EARTHQUAKE_OVERLAY_SOURCE_ID, diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 3e2e91fe1..cee572ee3 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -338,3 +338,53 @@ test('earthquake refresh reports failure and clears it only after a successful r layer.destroy(viewer); } }); + +test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => { + const originalFetch = globalThis.fetch; + const dataSources = []; + const publications = []; + const viewer = { dataSources: { + add(source) { dataSources.push(source); }, remove() { return true; }, + } }; + const layer = createEarthquakesLayer({ overlayHost: { + setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {}, + } }); + const good = { id: 'good', geometry: { type: 'Point', coordinates: [10, 20, 5] }, + properties: { mag: 4, time: 100, place: 'Fixture' } }; + const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); }; + try { + layer.init(viewer); + layer.enable(viewer); + respond([good]); + assert.equal(await layer.update(viewer), true); + const entity = dataSources[0].entities.values[0]; + const stats = layer.getStats(); + for (const bad of [null, { ...good, geometry: null }, + { ...good, geometry: { type: 'LineString', coordinates: [1, 2] } }, + { ...good, geometry: { coordinates: [181, 20] } }, + { ...good, geometry: { coordinates: [10, -91] } }, + { ...good, geometry: { coordinates: [null, 20] } }, + { ...good, properties: { mag: '4' } }, + { ...good, properties: { mag: Infinity } }, good]) { + respond([good, bad]); + assert.equal(await layer.update(viewer), false); + assert.equal(dataSources[0].entities.values.length, 1); + assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(layer.getStats().count, stats.count); + assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); + assert.equal(layer.getStats().error, 'Malformed USGS response'); + assert.equal(publications.length, 1); + } + respond([{ ...good, properties: { mag: null } }]); + assert.equal(await layer.update(viewer), true); + assert.equal(dataSources[0].entities.values.length, 0); + assert.equal(layer.getStats().error, null); + assert.equal(publications.at(-1)[1].length, 0); + respond([good]); + assert.equal(await layer.update(viewer), true); + assert.equal(dataSources[0].entities.values.length, 1); + } finally { + globalThis.fetch = originalFetch; + layer.destroy(viewer); + } +}); From ac3926c7dba5a2435e40e4b3748b36c729416cba Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 10 Sep 2026 19:56:01 -0700 Subject: [PATCH 2/2] fix(earthquakes): reject malformed feature properties --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/data/earthquakes.js | 1 + src/data/earthquakes.test.mjs | 13 +++++++++++++ 4 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc58e8fd8..0d2fe2a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfd9683ae..4132fde82 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,8 @@ Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 476b72c18..cb4d3ac13 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -130,6 +130,7 @@ export function normalizeEarthquakeSnapshot(geojson) { const coordinates = feature?.geometry?.coordinates; const properties = feature?.properties; if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties + || typeof properties !== 'object' || Array.isArray(properties) || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null; const [lon, lat, depthKm] = coordinates; const mag = properties.mag; diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index cee572ee3..8119a6dc7 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -359,6 +359,19 @@ test('malformed earthquake refresh preserves entities, overlays, count and times assert.equal(await layer.update(viewer), true); const entity = dataSources[0].entities.values[0]; const stats = layer.getStats(); + for (const properties of ['invalid', 123, [], true]) { + const bad = { ...good, id: 'bad-properties', properties }; + for (const features of [[bad], [good, bad]]) { + respond(features); + assert.equal(await layer.update(viewer), false); + assert.equal(dataSources[0].entities.values.length, 1); + assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(layer.getStats().count, stats.count); + assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); + assert.equal(layer.getStats().error, 'Malformed USGS response'); + assert.equal(publications.length, 1); + } + } for (const bad of [null, { ...good, geometry: null }, { ...good, geometry: { type: 'LineString', coordinates: [1, 2] } }, { ...good, geometry: { coordinates: [181, 20] } },