Compare commits
1 Commits
codex/issu
...
b2f604b120
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f604b120 |
@@ -59,7 +59,8 @@ export async function runSource(name, fn, ...args) {
|
|||||||
});
|
});
|
||||||
const data = await Promise.race([dataPromise, timeoutPromise]);
|
const data = await Promise.race([dataPromise, timeoutPromise]);
|
||||||
const hasError = Boolean(data?.error);
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
status: isDegraded ? 'degraded' : 'ok',
|
status: isDegraded ? 'degraded' : 'ok',
|
||||||
|
|||||||
@@ -15,31 +15,6 @@ const API_BASE = 'https://acleddata.com/api/acled/read';
|
|||||||
// Session cache
|
// Session cache
|
||||||
let sessionCache = { cookies: null, token: null, method: null, expires: 0 };
|
let sessionCache = { cookies: null, token: null, method: null, expires: 0 };
|
||||||
|
|
||||||
function acledCredentials() {
|
|
||||||
return {
|
|
||||||
email: process.env.ACLED_EMAIL || process.env.ACLED_USER || process.env.ACLED_USERNAME,
|
|
||||||
password: process.env.ACLED_PASSWORD,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeAuthHeaders(headers) {
|
|
||||||
const safe = { ...headers };
|
|
||||||
if (safe.Authorization) safe.Authorization = 'Bearer [redacted]';
|
|
||||||
if (safe.Cookie) safe.Cookie = '[redacted]';
|
|
||||||
return safe;
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyAuthFailure(status, body = '') {
|
|
||||||
if (status === 401 || status === 403) return 'auth_denied';
|
|
||||||
if (status >= 500) return 'auth_endpoint_failed';
|
|
||||||
if (/invalid|denied|unauthorized|forbidden/i.test(body)) return 'auth_denied';
|
|
||||||
return 'auth_endpoint_failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetAcledSessionForTests() {
|
|
||||||
sessionCache = { cookies: null, token: null, method: null, expires: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 1: Cookie-based session login (mirrors browser login)
|
// Strategy 1: Cookie-based session login (mirrors browser login)
|
||||||
async function loginCookie(email, password) {
|
async function loginCookie(email, password) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -68,14 +43,11 @@ async function loginCookie(email, password) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errText = await res.text().catch(() => '');
|
const errText = await res.text().catch(() => '');
|
||||||
return {
|
return { error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
|
||||||
error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
|
|
||||||
code: classifyAuthFailure(res.status, errText),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : '';
|
const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : '';
|
||||||
return { error: `Cookie login error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
|
return { error: `Cookie login error: ${e.message}${cause}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,30 +73,28 @@ async function loginOAuth(email, password) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text().catch(() => '');
|
const errText = await res.text().catch(() => '');
|
||||||
return {
|
return { error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
|
||||||
error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
|
|
||||||
code: classifyAuthFailure(res.status, errText),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.access_token) {
|
if (!data.access_token) {
|
||||||
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}`, code: 'auth_endpoint_failed' };
|
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token: data.access_token };
|
return { token: data.access_token };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : '';
|
const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : '';
|
||||||
return { error: `OAuth error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
|
return { error: `OAuth error: ${e.message}${cause}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try both auth strategies
|
// Try both auth strategies
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const { email, password } = acledCredentials();
|
const email = process.env.ACLED_EMAIL;
|
||||||
|
const password = process.env.ACLED_PASSWORD;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return { error: 'No ACLED credentials. Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env.', code: 'no_credentials' };
|
return { error: 'No ACLED credentials. Set ACLED_EMAIL and ACLED_PASSWORD in .env.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return cached session if still valid
|
// Return cached session if still valid
|
||||||
@@ -138,7 +108,7 @@ async function authenticate() {
|
|||||||
// Try OAuth first (official programmatic method per ACLED docs)
|
// Try OAuth first (official programmatic method per ACLED docs)
|
||||||
const oauthResult = await loginOAuth(email, password);
|
const oauthResult = await loginOAuth(email, password);
|
||||||
if (oauthResult.token) {
|
if (oauthResult.token) {
|
||||||
if (debug) console.error('[ACLED DEBUG] OAuth OK');
|
if (debug) console.error(`[ACLED DEBUG] OAuth OK — token: ${oauthResult.token.slice(0, 20)}...`);
|
||||||
sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 };
|
sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 };
|
||||||
return sessionCache;
|
return sessionCache;
|
||||||
}
|
}
|
||||||
@@ -148,14 +118,13 @@ async function authenticate() {
|
|||||||
// Fall back to cookie-based session
|
// Fall back to cookie-based session
|
||||||
const cookieResult = await loginCookie(email, password);
|
const cookieResult = await loginCookie(email, password);
|
||||||
if (cookieResult.cookies) {
|
if (cookieResult.cookies) {
|
||||||
if (debug) console.error('[ACLED DEBUG] Cookie OK');
|
if (debug) console.error(`[ACLED DEBUG] Cookie OK — cookies: ${cookieResult.cookies.slice(0, 80)}...`);
|
||||||
sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 };
|
sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 };
|
||||||
return sessionCache;
|
return sessionCache;
|
||||||
}
|
}
|
||||||
errors.push(`Cookie: ${cookieResult.error}`);
|
errors.push(`Cookie: ${cookieResult.error}`);
|
||||||
|
|
||||||
const code = [oauthResult.code, cookieResult.code].includes('auth_denied') ? 'auth_denied' : 'auth_endpoint_failed';
|
return { error: `All ACLED auth methods failed.\n${errors.join('\n')}` };
|
||||||
return { error: `All ACLED auth methods failed.\n${errors.join('\n')}`, code };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build headers based on auth method
|
// Build headers based on auth method
|
||||||
@@ -191,7 +160,7 @@ export async function getEvents(opts = {}) {
|
|||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const session = await authenticate();
|
const session = await authenticate();
|
||||||
if (session.error) return { error: session.error, code: session.code || 'auth_failed' };
|
if (session.error) return { error: session.error };
|
||||||
|
|
||||||
const params = new URLSearchParams({ _format: 'json', limit: String(limit) });
|
const params = new URLSearchParams({ _format: 'json', limit: String(limit) });
|
||||||
if (eventDateStart && eventDateEnd) {
|
if (eventDateStart && eventDateEnd) {
|
||||||
@@ -208,7 +177,7 @@ export async function getEvents(opts = {}) {
|
|||||||
const hdrs = authHeaders(session);
|
const hdrs = authHeaders(session);
|
||||||
if (debug) {
|
if (debug) {
|
||||||
console.error(`[ACLED DEBUG] Data request: GET ${url}`);
|
console.error(`[ACLED DEBUG] Data request: GET ${url}`);
|
||||||
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(sanitizeAuthHeaders(hdrs))}`);
|
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(hdrs)}`);
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), 25000);
|
const timer = setTimeout(() => controller.abort(), 25000);
|
||||||
@@ -232,28 +201,25 @@ export async function getEvents(opts = {}) {
|
|||||||
+ ' 3. Ensure your account has the "API" access group\n'
|
+ ' 3. Ensure your account has the "API" access group\n'
|
||||||
+ ' Contact access@acleddata.com if issues persist.'
|
+ ' Contact access@acleddata.com if issues persist.'
|
||||||
: '';
|
: '';
|
||||||
return {
|
return { error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}` };
|
||||||
error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}`,
|
|
||||||
code: 'auth_denied',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}`, code: 'api_failed' };
|
return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// ACLED may return a 200 with an error status in the body
|
// ACLED may return a 200 with an error status in the body
|
||||||
if (data?.status && data.status !== 200) {
|
if (data?.status && data.status !== 200) {
|
||||||
return { error: `ACLED API error: status ${data.status} - ${data.message || 'Unknown error'}`, code: 'api_failed' };
|
return { error: `ACLED API error: status ${data.status} — ${data.message || 'Unknown error'}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
return { error: 'ACLED data request timed out (25s)', code: 'api_failed' };
|
return { error: 'ACLED data request timed out (25s)' };
|
||||||
}
|
}
|
||||||
const rootCause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
|
const rootCause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
|
||||||
return { error: `ACLED data error: ${e.message}${rootCause ? ' - ' + rootCause : ''}`, code: 'api_failed' };
|
return { error: `ACLED data error: ${e.message}${rootCause ? ' → ' + rootCause : ''}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,13 +237,12 @@ function groupBy(events, field) {
|
|||||||
|
|
||||||
// Briefing — last 7 days of global conflict events
|
// Briefing — last 7 days of global conflict events
|
||||||
export async function briefing() {
|
export async function briefing() {
|
||||||
const { email, password } = acledCredentials();
|
if (!process.env.ACLED_EMAIL || !process.env.ACLED_PASSWORD) {
|
||||||
if (!email || !password) {
|
|
||||||
return {
|
return {
|
||||||
source: 'ACLED',
|
source: 'ACLED',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'no_credentials',
|
status: 'no_credentials',
|
||||||
message: 'Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
|
message: 'Set ACLED_EMAIL and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +256,7 @@ export async function briefing() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (data?.error) {
|
if (data?.error) {
|
||||||
const status = data.code === 'auth_denied' ? 'auth_failed' : 'api_failed';
|
return { source: 'ACLED', timestamp: new Date().toISOString(), error: data.error };
|
||||||
return { source: 'ACLED', timestamp: new Date().toISOString(), status, error: data.error };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let events = data?.data || [];
|
let events = data?.data || [];
|
||||||
@@ -336,7 +300,6 @@ export async function briefing() {
|
|||||||
return {
|
return {
|
||||||
source: 'ACLED',
|
source: 'ACLED',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'live',
|
|
||||||
period: { start, end },
|
period: { start, end },
|
||||||
totalEvents: events.length,
|
totalEvents: events.length,
|
||||||
totalFatalities,
|
totalFatalities,
|
||||||
@@ -344,7 +307,6 @@ export async function briefing() {
|
|||||||
byType,
|
byType,
|
||||||
topCountries,
|
topCountries,
|
||||||
deadliestEvents,
|
deadliestEvents,
|
||||||
message: events.length === 0 ? 'ACLED returned a valid empty event set for the requested period.' : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
||||||
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
||||||
// Public feed access varies; RapidAPI tier available for programmatic use.
|
// 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';
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
// Get all military aircraft
|
// Get all military aircraft
|
||||||
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
|
source: 'adsb-rapidapi',
|
||||||
headers: {
|
headers: {
|
||||||
'X-RapidAPI-Key': apiKey,
|
'X-RapidAPI-Key': apiKey,
|
||||||
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
||||||
@@ -151,21 +153,26 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
|
|
||||||
// Attempt to fetch from public feed
|
// Attempt to fetch from public feed
|
||||||
async function fetchPublicFeed() {
|
async function fetchPublicFeed() {
|
||||||
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000 });
|
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000, source: 'adsb-public' });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get military aircraft from available sources
|
async function getMilitaryAircraftResult(apiKey) {
|
||||||
export async function getMilitaryAircraft(apiKey) {
|
const failures = [];
|
||||||
|
|
||||||
// Try RapidAPI first if key available
|
// Try RapidAPI first if key available
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const data = await fetchViaRapidApi(apiKey);
|
const data = await fetchViaRapidApi(apiKey);
|
||||||
if (data && !data.error) {
|
if (data && !data.error) {
|
||||||
const aircraft = data.ac || data.aircraft || [];
|
const aircraft = data.ac || data.aircraft || [];
|
||||||
if (Array.isArray(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
|
// Try public feed
|
||||||
@@ -173,11 +180,21 @@ export async function getMilitaryAircraft(apiKey) {
|
|||||||
if (pubData && !pubData.error) {
|
if (pubData && !pubData.error) {
|
||||||
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
||||||
if (Array.isArray(aircraft)) {
|
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
|
// 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
|
// Briefing — attempt to get military flight data, document what's available
|
||||||
export async function briefing() {
|
export async function briefing() {
|
||||||
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
|
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 we got data, analyze it
|
||||||
if (militaryAircraft && militaryAircraft.length > 0) {
|
if (militaryAircraft && militaryAircraft.length > 0) {
|
||||||
@@ -255,6 +273,7 @@ export async function briefing() {
|
|||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'live',
|
status: 'live',
|
||||||
|
provider: result.provider,
|
||||||
totalMilitary: militaryAircraft.length,
|
totalMilitary: militaryAircraft.length,
|
||||||
byCountry,
|
byCountry,
|
||||||
categories: {
|
categories: {
|
||||||
@@ -269,10 +288,18 @@ export async function briefing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No data available — return stub with integration documentation
|
// 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 {
|
return {
|
||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: apiKey ? 'error' : 'no_key',
|
status,
|
||||||
|
provider: result.provider,
|
||||||
|
error,
|
||||||
|
failures: result.failures,
|
||||||
militaryAircraft: [],
|
militaryAircraft: [],
|
||||||
message: apiKey
|
message: apiKey
|
||||||
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ Source docs:
|
|||||||
- [Telegram](telegram.md)
|
- [Telegram](telegram.md)
|
||||||
- [FIRMS](firms.md)
|
- [FIRMS](firms.md)
|
||||||
- [Maritime](maritime.md)
|
- [Maritime](maritime.md)
|
||||||
|
- [ADS-B](adsb.md)
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
Provides conflict events, fatalities, event types, and locations.
|
Provides conflict events, fatalities, event types, and locations.
|
||||||
|
|
||||||
- Auth: `ACLED_EMAIL` or `ACLED_USER`, plus `ACLED_PASSWORD`.
|
- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`.
|
||||||
- Flow: OAuth password grant is tried first, then cookie session fallback.
|
- Flow: OAuth password grant is tried first, then cookie session fallback.
|
||||||
- Failure modes: missing credentials (`no_credentials`), rejected credentials or access denied (`auth_failed`), token/API endpoint failure (`api_failed`), and valid empty event sets (`totalEvents: 0`).
|
- Failure modes: missing credentials, terms/profile not completed, expired token, account missing API access.
|
||||||
- Behavior: missing or rejected credentials produce degraded source health with the ACLED error text.
|
- Behavior: missing or rejected credentials produce degraded source health with the ACLED error text.
|
||||||
- Debug logs redact bearer tokens and cookies.
|
|
||||||
- Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.
|
- Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.
|
||||||
|
|||||||
24
docs/sources/adsb.md
Normal file
24
docs/sources/adsb.md
Normal file
@@ -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=<rapidapi-key>
|
||||||
|
```
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"brief:save": "node apis/save-briefing.mjs",
|
"brief:save": "node apis/save-briefing.mjs",
|
||||||
"diag": "node diag.mjs",
|
"diag": "node diag.mjs",
|
||||||
"test": "npm run test:unit",
|
"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: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/adsb.test.mjs",
|
||||||
"compose:config": "docker compose config",
|
"compose:config": "docker compose config",
|
||||||
"clean": "node scripts/clean.mjs",
|
"clean": "node scripts/clean.mjs",
|
||||||
"fresh-start": "npm run clean && npm start"
|
"fresh-start": "npm run clean && npm start"
|
||||||
|
|||||||
82
test/adsb.test.mjs
Normal file
82
test/adsb.test.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -34,129 +34,3 @@ test('safeFetchText returns text and byte count', async () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(payload, ok = true, status = 200) {
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
headers: { getSetCookie: () => [], get: () => 'application/json' },
|
|
||||||
text: async () => JSON.stringify(payload),
|
|
||||||
json: async () => payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function textResponse(text, ok = false, status = 500) {
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
headers: { getSetCookie: () => [], get: () => 'text/plain' },
|
|
||||||
text: async () => text,
|
|
||||||
json: async () => JSON.parse(text),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withAcledEnv(mockFetch, fn) {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const saved = {
|
|
||||||
ACLED_EMAIL: process.env.ACLED_EMAIL,
|
|
||||||
ACLED_USER: process.env.ACLED_USER,
|
|
||||||
ACLED_USERNAME: process.env.ACLED_USERNAME,
|
|
||||||
ACLED_PASSWORD: process.env.ACLED_PASSWORD,
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetch;
|
|
||||||
delete process.env.ACLED_EMAIL;
|
|
||||||
delete process.env.ACLED_USER;
|
|
||||||
delete process.env.ACLED_USERNAME;
|
|
||||||
delete process.env.ACLED_PASSWORD;
|
|
||||||
const acled = await import('../apis/sources/acled.mjs');
|
|
||||||
acled.resetAcledSessionForTests();
|
|
||||||
try {
|
|
||||||
return await fn(acled);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
for (const [key, value] of Object.entries(saved)) {
|
|
||||||
if (value === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = value;
|
|
||||||
}
|
|
||||||
acled.resetAcledSessionForTests();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('ACLED credentialed OAuth success returns live events and supports ACLED_USER', async () => {
|
|
||||||
const responses = [
|
|
||||||
jsonResponse({ access_token: 'secret-token' }),
|
|
||||||
jsonResponse({
|
|
||||||
status: 200,
|
|
||||||
data: [{
|
|
||||||
event_date: '2026-05-17',
|
|
||||||
event_type: 'Protests',
|
|
||||||
sub_event_type: 'Peaceful protest',
|
|
||||||
country: 'Example',
|
|
||||||
region: 'Example Region',
|
|
||||||
location: 'Example City',
|
|
||||||
fatalities: '0',
|
|
||||||
latitude: '1.23',
|
|
||||||
longitude: '4.56',
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
|
|
||||||
process.env.ACLED_USER = 'operator@example.test';
|
|
||||||
process.env.ACLED_PASSWORD = 'password';
|
|
||||||
const data = await briefing();
|
|
||||||
|
|
||||||
assert.equal(data.status, 'live');
|
|
||||||
assert.equal(data.totalEvents, 1);
|
|
||||||
assert.equal(data.topCountries.Example.count, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ACLED rejected credentials return auth_failed diagnostics', async () => {
|
|
||||||
const responses = [
|
|
||||||
textResponse('invalid credentials', false, 401),
|
|
||||||
textResponse('forbidden', false, 403),
|
|
||||||
];
|
|
||||||
|
|
||||||
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
|
|
||||||
process.env.ACLED_EMAIL = 'operator@example.test';
|
|
||||||
process.env.ACLED_PASSWORD = 'wrong-password';
|
|
||||||
const data = await briefing();
|
|
||||||
|
|
||||||
assert.equal(data.status, 'auth_failed');
|
|
||||||
assert.match(data.error, /All ACLED auth methods failed/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ACLED token endpoint failure returns api_failed diagnostics', async () => {
|
|
||||||
const responses = [
|
|
||||||
textResponse('temporary outage', false, 503),
|
|
||||||
textResponse('temporary outage', false, 503),
|
|
||||||
];
|
|
||||||
|
|
||||||
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
|
|
||||||
process.env.ACLED_EMAIL = 'operator@example.test';
|
|
||||||
process.env.ACLED_PASSWORD = 'password';
|
|
||||||
const data = await briefing();
|
|
||||||
|
|
||||||
assert.equal(data.status, 'api_failed');
|
|
||||||
assert.match(data.error, /All ACLED auth methods failed/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ACLED valid empty response is live with zero events', async () => {
|
|
||||||
const responses = [
|
|
||||||
jsonResponse({ access_token: 'secret-token' }),
|
|
||||||
jsonResponse({ status: 200, data: [] }),
|
|
||||||
];
|
|
||||||
|
|
||||||
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
|
|
||||||
process.env.ACLED_EMAIL = 'operator@example.test';
|
|
||||||
process.env.ACLED_PASSWORD = 'password';
|
|
||||||
const data = await briefing();
|
|
||||||
|
|
||||||
assert.equal(data.status, 'live');
|
|
||||||
assert.equal(data.totalEvents, 0);
|
|
||||||
assert.match(data.message, /valid empty/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user