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

+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/,
);