Merge main into fix/ais-static-keeps-motion

This commit is contained in:
Sameh Khamis committed 2026-10-24 21:39:32 -07:00
commit a2a3aba1d2
38 files changed
+839 -150

No files matched your search

+72 -59
View File
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import os from 'node:os';
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import {
coalesceProxyRequest,
@@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) {
}
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
let _openskyAuthWarned = false;
/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */
let _openskyCredentialsFileWarned = false;
/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */
let _openskyBasicRetiredWarned = false;
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
let _openskyAuthModeWarned = false;
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
@@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// current instead of coasting a stale worldwide frame indefinitely.
const OPENSKY_SOURCE_STALE_MS = 120_000;
/**
* The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and
* OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account
* page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home
* directory). The file is read on each token refresh, so a replaced file is
* picked up without a restart.
*
* @param {Record<string, string|undefined>} [env]
* @param {(path: string) => string} [readFile]
* @returns {{ clientId: string, clientSecret: string } | null}
*/
export function openSkyClientCredentials(
env = process.env,
readFile = (path) => fs.readFileSync(path, 'utf8'),
) {
const clientId = String(env.OPENSKY_CLIENT_ID || '').trim();
const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim();
if (clientId && clientSecret) return { clientId, clientSecret };
const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim();
if (!file) return null;
const path = file.replace(/^~(?=$|[\\/])/, os.homedir());
let raw;
try {
raw = JSON.parse(readFile(path));
} catch {
if (!_openskyCredentialsFileWarned) {
console.warn(
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
);
_openskyCredentialsFileWarned = true;
}
return null;
}
const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim();
const fileSecret = String(
raw?.clientSecret ?? raw?.client_secret ?? '',
).trim();
return fileId && fileSecret
? { clientId: fileId, clientSecret: fileSecret }
: null;
}
/**
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
*
@@ -174,9 +222,9 @@ export async function getOpenSkyToken() {
// return the same promise instead of issuing a duplicate token request
if (_openskyTokenPromise) return _openskyTokenPromise;
const clientId = process.env.OPENSKY_CLIENT_ID;
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
const credentials = openSkyClientCredentials();
if (!credentials) return null;
const { clientId, clientSecret } = credentials;
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
_openskyTokenPromise = (async () => {
@@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
* - 'oauth' (default) — client_credentials bearer token
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
* - 'auto' — try OAuth first, fall back to Basic, then anon
* - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and
* password) on 2026-03-18, so this requests anonymously and
* warns once
* - 'auto' — try OAuth first, then anon
* - 'anon' — no credentials
*
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
@@ -524,21 +574,21 @@ export function openSkyProxy() {
_openskySnapshotInFlight,
'global',
async () => {
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
// OpenSky no longer accepts a Basic header, so sending
// OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask
// anonymously and say once what to configure instead.
if (!_openskyBasicRetiredWarned) {
console.warn(
'[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
);
_openskyBasicRetiredWarned = true;
}
reason = 'basic_retired';
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
@@ -554,31 +604,12 @@ export function openSkyProxy() {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
reason = 'missing_oauth_creds';
}
}
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
const { upstream, body } = await fetchOpenSkyStates(headers);
const sourceEpochMs = upstream.ok
? openSkySourceEpochMs(body)
@@ -586,12 +617,6 @@ export function openSkyProxy() {
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
@@ -627,12 +652,11 @@ export function openSkyProxy() {
body,
usedMode,
reason,
hasBasicCreds,
sourceEpochMs,
};
},
);
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
let { upstream, body, usedMode, reason, sourceEpochMs } =
await refresh.promise;
if (
upstream.ok &&
@@ -684,35 +708,24 @@ export function openSkyProxy() {
}
if (upstream.status === 401 || upstream.status === 403) {
if (requestedMode === 'basic' && !hasBasicCreds) {
if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
});
reason = 'missing_basic_creds';
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
});
reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') {
body = JSON.stringify({
error: 'OpenSky auth invalid. Username/password were rejected.',
});
reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth client credentials were rejected.',
});
reason = 'oauth_invalid_credentials';
} else if (requestedMode === 'auto' && !hasBasicCreds) {
} else if (requestedMode === 'auto') {
body = JSON.stringify({
error:
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
});
reason = 'missing_oauth_and_basic_creds';
reason = 'missing_oauth_creds';
} else {
body = JSON.stringify({
error: 'OpenSky auth required.',
@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { openSkyClientCredentials } from './opensky.js';
const file = (contents) => (filePath) => {
file.read = filePath;
if (contents instanceof Error) throw contents;
return contents;
};
test('OpenSky client env vars come first', () => {
const read = () => assert.fail('the file is not read when env vars are set');
assert.deepEqual(
openSkyClientCredentials(
{
OPENSKY_CLIENT_ID: ' id ',
OPENSKY_CLIENT_SECRET: 'secret',
OPENSKY_CREDENTIALS_FILE: 'credentials.json',
},
read,
),
{ clientId: 'id', clientSecret: 'secret' },
);
});
test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => {
const env = {
OPENSKY_CLIENT_ID: '',
OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json',
};
// The file OpenSky's account page downloads uses camelCase keys.
assert.deepEqual(
openSkyClientCredentials(
env,
file('{"clientId":"cid","clientSecret":"csecret"}'),
),
{ clientId: 'cid', clientSecret: 'csecret' },
);
assert.equal(
path.normalize(file.read),
path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')),
);
assert.deepEqual(
openSkyClientCredentials(
env,
file('{"client_id":"cid","client_secret":"csecret"}'),
),
{ clientId: 'cid', clientSecret: 'csecret' },
);
});
test('a missing, unreadable or incomplete credentials file means no client', () => {
assert.equal(openSkyClientCredentials({}, file('{}')), null);
const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' };
const missing = Object.assign(new Error('nope'), { code: 'ENOENT' });
assert.equal(openSkyClientCredentials(env, file(missing)), null);
assert.equal(openSkyClientCredentials(env, file('not json')), null);
assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null);
});
test('credential file warnings never expose file contents or read errors', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (message) => warnings.push(message));
for (const contents of [
'{"clientId":"id","clientSecret":FAKE_SECRET_123}',
new Error('could not read FAKE_SECRET_123'),
]) {
const { openSkyClientCredentials: resolve } = await import(
`./opensky.js?warning=${warnings.length}`
);
const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' };
assert.equal(resolve(env, file(contents)), null);
assert.equal(resolve(env, file(contents)), null);
}
assert.deepEqual(warnings, [
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
]);
});
+6 -1
View File
@@ -43,6 +43,11 @@ function number(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in
// knots); convert so `speedKt` means what it says.
function knotsFromMph(mph) {
return mph === null ? null : Math.round(mph * 0.868976);
}
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
// up to about half an hour before; allow that, but not a clock far ahead.
const EARLY_ADVISORY_MS = 90 * 60_000;
@@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) {
pressureHpa: number(raw.pressure, 800, 1100),
movement: {
directionDegrees: number(raw.movementDir, 0, 360),
speedKt: number(raw.movementSpeed, 0, 200),
speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)),
},
advisoryUrl: officialLink(forecast.url),
outlookUrl: raw.id.startsWith('al')