diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 2cb5f2e..b2b4bea 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -59,7 +59,8 @@ export async function runSource(name, fn, ...args) { }); const data = await Promise.race([dataPromise, timeoutPromise]); const hasError = Boolean(data?.error); - const isDegraded = hasError || ['no_credentials', 'degraded', 'failed'].includes(data?.status); + const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error']; + const isDegraded = hasError || degradedStatuses.includes(data?.status); return { name, status: isDegraded ? 'degraded' : 'ok', diff --git a/apis/sources/adsb.mjs b/apis/sources/adsb.mjs index b954d75..b05c5a8 100644 --- a/apis/sources/adsb.mjs +++ b/apis/sources/adsb.mjs @@ -1,7 +1,8 @@ // ADS-B Exchange — Unfiltered Flight Tracking (including Military) // Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft. // Public feed access varies; RapidAPI tier available for programmatic use. -// This module attempts the public endpoints and falls back to a documented stub. +// This module reports explicit disabled/degraded state instead of making +// unavailable aircraft data look live. import { safeFetch } from '../utils/fetch.mjs'; @@ -140,6 +141,7 @@ async function fetchViaRapidApi(apiKey) { // Get all military aircraft const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, { timeout: 20000, + source: 'adsb-rapidapi', headers: { 'X-RapidAPI-Key': apiKey, 'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com', @@ -151,21 +153,26 @@ async function fetchViaRapidApi(apiKey) { // Attempt to fetch from public feed async function fetchPublicFeed() { - const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000 }); + const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000, source: 'adsb-public' }); return data; } -// Get military aircraft from available sources -export async function getMilitaryAircraft(apiKey) { +async function getMilitaryAircraftResult(apiKey) { + const failures = []; + // Try RapidAPI first if key available if (apiKey) { const data = await fetchViaRapidApi(apiKey); if (data && !data.error) { const aircraft = data.ac || data.aircraft || []; if (Array.isArray(aircraft)) { - return aircraft.map(classifyAircraft).filter(a => a.isMilitary); + return { + provider: 'rapidapi', + aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary), + }; } } + failures.push({ provider: 'rapidapi', error: data?.error || 'RapidAPI returned an unsupported payload' }); } // Try public feed @@ -173,11 +180,21 @@ export async function getMilitaryAircraft(apiKey) { if (pubData && !pubData.error) { const aircraft = pubData.ac || pubData.aircraft || pubData.states || []; if (Array.isArray(aircraft)) { - return aircraft.map(classifyAircraft).filter(a => a.isMilitary); + return { + provider: 'public-feed', + aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary), + }; } } + failures.push({ provider: 'public-feed', error: pubData?.error || 'Public feed returned an unsupported payload' }); - return null; // all sources failed + return { provider: null, aircraft: null, failures }; +} + +// Get military aircraft from available sources +export async function getMilitaryAircraft(apiKey) { + const result = await getMilitaryAircraftResult(apiKey); + return result.aircraft; } // Get all aircraft in a geographic bounding box via RapidAPI @@ -208,7 +225,8 @@ export async function getAircraftInArea(lat, lon, radiusNm = 250, apiKey) { // Briefing — attempt to get military flight data, document what's available export async function briefing() { const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null; - const militaryAircraft = await getMilitaryAircraft(apiKey); + const result = await getMilitaryAircraftResult(apiKey); + const militaryAircraft = result.aircraft; // If we got data, analyze it if (militaryAircraft && militaryAircraft.length > 0) { @@ -255,6 +273,7 @@ export async function briefing() { source: 'ADS-B Exchange', timestamp: new Date().toISOString(), status: 'live', + provider: result.provider, totalMilitary: militaryAircraft.length, byCountry, categories: { @@ -269,10 +288,18 @@ export async function briefing() { } // No data available — return stub with integration documentation + const status = apiKey ? 'degraded' : 'disabled'; + const error = apiKey + ? 'ADS-B providers returned no usable aircraft data' + : 'ADSB_API_KEY or RAPIDAPI_KEY is not configured'; + return { source: 'ADS-B Exchange', timestamp: new Date().toISOString(), - status: apiKey ? 'error' : 'no_key', + status, + provider: result.provider, + error, + failures: result.failures, militaryAircraft: [], message: apiKey ? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.' diff --git a/docs/sources/README.md b/docs/sources/README.md index e8549a4..a0a2b2c 100644 --- a/docs/sources/README.md +++ b/docs/sources/README.md @@ -16,4 +16,5 @@ Source docs: - [Telegram](telegram.md) - [FIRMS](firms.md) - [Maritime](maritime.md) +- [ADS-B](adsb.md) - [Reddit](reddit.md) diff --git a/docs/sources/adsb.md b/docs/sources/adsb.md new file mode 100644 index 0000000..f4b9790 --- /dev/null +++ b/docs/sources/adsb.md @@ -0,0 +1,24 @@ +# ADS-B Source + +ADS-B Exchange support is optional and intended for unfiltered aircraft and military-flight awareness. + +- Source module: `apis/sources/adsb.mjs` +- Preferred provider: ADS-B Exchange via RapidAPI +- Credentials: `ADSB_API_KEY` or `RAPIDAPI_KEY` +- Runtime status without credentials: `disabled` +- Runtime status when providers fail: `degraded` +- Runtime status with usable aircraft payloads: `live` + +The source does not treat a missing key or unavailable public feed as normal live data. `/api/health` and `/api/metrics` surface the degraded source state through the sweep source summary. + +Known failure modes: + +- Missing `ADSB_API_KEY` / `RAPIDAPI_KEY`: source is disabled with operator guidance. +- RapidAPI rejects or rate-limits the request: source is degraded and records provider failure detail. +- Public feed is blocked, rate-limited, or changes shape: source remains degraded instead of returning stale-looking data. + +Register for the provider documented in the README, then set: + +```env +ADSB_API_KEY= +``` diff --git a/package.json b/package.json index d1a48e4..c87b141 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "brief:save": "node apis/save-briefing.mjs", "diag": "node diag.mjs", "test": "npm run test:unit", - "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/dashboard-geotagging.test.mjs", + "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs", "compose:config": "docker compose config", "clean": "node scripts/clean.mjs", "fresh-start": "npm run clean && npm start" diff --git a/test/adsb.test.mjs b/test/adsb.test.mjs new file mode 100644 index 0000000..b9e3e4d --- /dev/null +++ b/test/adsb.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +async function withFetch(mockFetch, fn) { + const originalFetch = globalThis.fetch; + const originalAdsbKey = process.env.ADSB_API_KEY; + const originalRapidKey = process.env.RAPIDAPI_KEY; + globalThis.fetch = mockFetch; + delete process.env.ADSB_API_KEY; + delete process.env.RAPIDAPI_KEY; + try { + return await fn(); + } finally { + globalThis.fetch = originalFetch; + if (originalAdsbKey === undefined) delete process.env.ADSB_API_KEY; + else process.env.ADSB_API_KEY = originalAdsbKey; + if (originalRapidKey === undefined) delete process.env.RAPIDAPI_KEY; + else process.env.RAPIDAPI_KEY = originalRapidKey; + } +} + +function jsonResponse(payload, ok = true, status = 200) { + return { + ok, + status, + headers: { get: () => 'application/json' }, + text: async () => JSON.stringify(payload), + }; +} + +test('ADS-B reports disabled when no key is configured and public feed fails', async () => { + await withFetch(async () => jsonResponse({ error: 'blocked' }, false, 403), async () => { + const { briefing } = await import('../apis/sources/adsb.mjs'); + const data = await briefing(); + + assert.equal(data.status, 'disabled'); + assert.match(data.error, /ADSB_API_KEY|RAPIDAPI_KEY/); + assert.equal(data.militaryAircraft.length, 0); + }); +}); + +test('ADS-B reports degraded when RapidAPI and public feed fail', async () => { + await withFetch(async () => jsonResponse({ error: 'unavailable' }, false, 503), async () => { + process.env.ADSB_API_KEY = 'test-key'; + const { briefing } = await import('../apis/sources/adsb.mjs'); + const data = await briefing(); + + assert.equal(data.status, 'degraded'); + assert.match(data.error, /providers returned no usable/); + assert.equal(data.failures.length, 2); + }); +}); + +test('ADS-B returns live RapidAPI military aircraft payloads', async () => { + await withFetch(async () => jsonResponse({ + ac: [{ + hex: 'AE1234', + flight: 'RCH123', + t: 'KC135', + lat: 50, + lon: 8, + mil: true, + }], + }), async () => { + process.env.ADSB_API_KEY = 'test-key'; + const { briefing } = await import('../apis/sources/adsb.mjs'); + const data = await briefing(); + + assert.equal(data.status, 'live'); + assert.equal(data.provider, 'rapidapi'); + assert.equal(data.totalMilitary, 1); + assert.equal(data.militaryAircraft[0].callsign, 'RCH123'); + }); +}); + +test('runSource treats disabled source status as degraded health', async () => { + const { runSource } = await import('../apis/briefing.mjs'); + const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' })); + + assert.equal(result.status, 'degraded'); + assert.equal(result.error, null); +});