fix(gnss): date replayed snapshots by the proxy-reported age, not cross-clock fetchedAt

The layer compared the server's fetchedAt with the browser's clock, so a
skewed client clock could shift or void the 30-minute window. The proxy now
returns ageMs (its own now minus fetchedAt, at serve time, on fresh, cached
and stale answers); the source refuses a snapshot without a finite,
non-negative ageMs, and the layer dates rows as its now() - ageMs. fetchedAt
stays in the payload for display.

Tests: proxy cached/stale answers report ageMs on the server clock; the
source rejects missing/invalid/negative ageMs; the layer replay test drives
ageMs. qa-gnss stubs send ageMs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Leo PechnickiandClaude Opus 5.5 authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:20:43 +02:00
1 parent 7e570a79ad
commit 783c5b16f4
9 files changed
+55 -25

No files matched your search

+2 -1
View File
@@ -169,7 +169,8 @@
Keyless, worldwide wherever adsb.lol has receivers; reads
a capped 250 nm snapshot around the camera through `/api/gnss-integrity`,
which returns integrity fields only and serves the last snapshot (with its
original observation time, for at most 30 minutes) when the upstream fails.
original observation time and `ageMs`, for at most 30 minutes) when the
upstream fails.
Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A
degraded report keeps an aircraft degraded for 30 minutes after that report,
not after its latest healthy one; replayed snapshots keep their age, and
+1 -1
View File
@@ -3072,7 +3072,7 @@ its criteria cannot be silently ignored.
| GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's `fetchedAt`, which the proxy keeps on cached and stale answers, so a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor.
GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
+5 -1
View File
@@ -250,7 +250,11 @@ async function stubRoute(page, { ageMs = 0 } = {}) {
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
const ok = window.__gnssQaMode === 'fixture';
const body = ok
? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0) }
? {
...payload,
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
ageMs: window.__gnssQaAgeMs || 0,
}
: { error: 'gnss_integrity_unavailable' };
return Promise.resolve(
new Response(JSON.stringify(body), {
+4 -1
View File
@@ -145,7 +145,10 @@ export function gnssIntegrityProxy({
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(anchor);
json(200, stale ? { ...value, stale: true } : value, stale);
// ageMs is measured on this server's clock, so the browser can date
// the rows without comparing its clock to ours.
const ageMs = Math.max(0, now() - value.fetchedAt);
json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale);
} catch (error) {
if (error.status === 429) {
// Tell the browser how long the shared upstream cooldown has left.
+9 -1
View File
@@ -86,6 +86,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
assert.deepEqual(res.body, {
fetchedAt: 1234,
ageMs: 0,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
classifier: 'gev-nic-nacp-v1',
@@ -118,8 +119,14 @@ test('snapshots are cached per anchor and concurrent reads share one upstream ca
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
assert.equal(calls, 1);
clock = 59_000;
await request();
const cached = await request();
assert.equal(calls, 1);
assert.equal(cached.body.fetchedAt, 0);
assert.equal(
cached.body.ageMs,
59_000,
'a cached answer reports its age on the server clock',
);
clock = 61_000;
await request();
assert.equal(calls, 2);
@@ -146,6 +153,7 @@ test('an upstream failure serves the last snapshot as stale, else 502', async ()
0,
'a stale answer keeps the time it was observed',
);
assert.equal(stale.body.ageMs, 120_000);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
+4 -2
View File
@@ -157,9 +157,11 @@ export function createGnssIntegrityLayer({
return false;
// Date the rows by when the proxy observed them: a cached or stale
// replay keeps its original age instead of restarting the window.
accumulateGnssObservations(_observations, snapshot.rows, now(), {
// The proxy reports that age, so client and server clocks never mix.
const at = now();
accumulateGnssObservations(_observations, snapshot.rows, at, {
windowMs,
observedAt: snapshot.fetchedAt,
observedAt: at - snapshot.ageMs,
});
_cells = binGnssCells(_observations);
render();
+14 -9
View File
@@ -31,7 +31,13 @@ function harness(
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
source: { getSnapshot },
// The real source refuses an undated snapshot; stubs default to a fresh one.
source: {
async getSnapshot(...args) {
const snapshot = await getSnapshot(...args);
return { ageMs: 0, ...snapshot };
},
},
viewAnchor: () => {
anchors.push(anchor);
return anchor;
@@ -220,9 +226,9 @@ test('no view anchor means no request, and destroy releases the data source', as
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance } = harness(async () => ({
const { layer, sources, advance, now } = harness(async () => ({
rows: threeHealthy(),
fetchedAt: 1_000,
ageMs: now() - 1_000,
stale: true,
}));
layer.enable();
@@ -247,9 +253,9 @@ test('a replayed stale snapshot keeps its observation age and expires on time',
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance, now } = harness(async () => {
const { layer, sources, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy(), fetchedAt: now() };
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
@@ -266,9 +272,9 @@ test('cells expire during prolonged fetch failures', async () => {
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, now, setAnchor } = harness(async () => {
const { layer, sources, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy(), fetchedAt: now() };
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
@@ -285,9 +291,8 @@ test('cells expire while no view anchor is available', async () => {
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance, now } = harness(async () => ({
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
fetchedAt: now(),
}));
layer.enable();
await layer.update();
+8 -4
View File
@@ -25,13 +25,17 @@ export function createAdsbGnssSource({
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
// The observation time dates every row in the rolling window; an
// undated snapshot could only be treated as new, so it is refused.
if (!Number.isFinite(payload.fetchedAt))
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error('GNSS integrity snapshot has no observation time');
return {
rows: payload.rows,
fetchedAt: payload.fetchedAt,
ageMs: payload.ageMs,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
+8 -5
View File
@@ -10,6 +10,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
assert.ok(options.signal instanceof AbortSignal);
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v1',
rows: [{ hex: 'abc123' }],
});
@@ -22,6 +23,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v1',
@@ -31,16 +33,17 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }),
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time');
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
assert.equal(stale.fetchedAt, 7);
assert.equal(stale.classifier, null);
// Without an observation time the rows could only be dated as new.
for (const fetchedAt of [undefined, null, '7'])
// Without an observation age the rows could only be dated as new.
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt }),
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);