fix: classify acled auth diagnostics
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 50s

This commit is contained in:
MrSphay
2026-05-17 14:03:18 +02:00
parent c2d572e6f5
commit 5b176851c8
3 changed files with 188 additions and 23 deletions

View File

@@ -15,6 +15,31 @@ const API_BASE = 'https://acleddata.com/api/acled/read';
// Session cache
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)
async function loginCookie(email, password) {
const controller = new AbortController();
@@ -43,11 +68,14 @@ async function loginCookie(email, password) {
}
const errText = await res.text().catch(() => '');
return { error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
return {
error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
code: classifyAuthFailure(res.status, errText),
};
} catch (e) {
clearTimeout(timer);
const cause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `Cookie login error: ${e.message}${cause}` };
return { error: `Cookie login error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
}
}
@@ -73,28 +101,30 @@ async function loginOAuth(email, password) {
if (!res.ok) {
const errText = await res.text().catch(() => '');
return { error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
return {
error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
code: classifyAuthFailure(res.status, errText),
};
}
const data = await res.json();
if (!data.access_token) {
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}` };
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}`, code: 'auth_endpoint_failed' };
}
return { token: data.access_token };
} catch (e) {
clearTimeout(timer);
const cause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `OAuth error: ${e.message}${cause}` };
return { error: `OAuth error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
}
}
// Try both auth strategies
async function authenticate() {
const email = process.env.ACLED_EMAIL;
const password = process.env.ACLED_PASSWORD;
const { email, password } = acledCredentials();
if (!email || !password) {
return { error: 'No ACLED credentials. Set ACLED_EMAIL and ACLED_PASSWORD in .env.' };
return { error: 'No ACLED credentials. Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env.', code: 'no_credentials' };
}
// Return cached session if still valid
@@ -108,7 +138,7 @@ async function authenticate() {
// Try OAuth first (official programmatic method per ACLED docs)
const oauthResult = await loginOAuth(email, password);
if (oauthResult.token) {
if (debug) console.error(`[ACLED DEBUG] OAuth OK — token: ${oauthResult.token.slice(0, 20)}...`);
if (debug) console.error('[ACLED DEBUG] OAuth OK');
sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 };
return sessionCache;
}
@@ -118,13 +148,14 @@ async function authenticate() {
// Fall back to cookie-based session
const cookieResult = await loginCookie(email, password);
if (cookieResult.cookies) {
if (debug) console.error(`[ACLED DEBUG] Cookie OK — cookies: ${cookieResult.cookies.slice(0, 80)}...`);
if (debug) console.error('[ACLED DEBUG] Cookie OK');
sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 };
return sessionCache;
}
errors.push(`Cookie: ${cookieResult.error}`);
return { error: `All ACLED auth methods failed.\n${errors.join('\n')}` };
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')}`, code };
}
// Build headers based on auth method
@@ -160,7 +191,7 @@ export async function getEvents(opts = {}) {
} = opts;
const session = await authenticate();
if (session.error) return { error: session.error };
if (session.error) return { error: session.error, code: session.code || 'auth_failed' };
const params = new URLSearchParams({ _format: 'json', limit: String(limit) });
if (eventDateStart && eventDateEnd) {
@@ -177,7 +208,7 @@ export async function getEvents(opts = {}) {
const hdrs = authHeaders(session);
if (debug) {
console.error(`[ACLED DEBUG] Data request: GET ${url}`);
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(hdrs)}`);
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(sanitizeAuthHeaders(hdrs))}`);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 25000);
@@ -201,25 +232,28 @@ export async function getEvents(opts = {}) {
+ ' 3. Ensure your account has the "API" access group\n'
+ ' Contact access@acleddata.com if issues persist.'
: '';
return { error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}` };
return {
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)}` };
return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}`, code: 'api_failed' };
}
const data = await res.json();
// ACLED may return a 200 with an error status in the body
if (data?.status && data.status !== 200) {
return { error: `ACLED API error: status ${data.status} ${data.message || 'Unknown error'}` };
return { error: `ACLED API error: status ${data.status} - ${data.message || 'Unknown error'}`, code: 'api_failed' };
}
return data;
} catch (e) {
if (e.name === 'AbortError') {
return { error: 'ACLED data request timed out (25s)' };
return { error: 'ACLED data request timed out (25s)', code: 'api_failed' };
}
const rootCause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `ACLED data error: ${e.message}${rootCause ? ' ' + rootCause : ''}` };
return { error: `ACLED data error: ${e.message}${rootCause ? ' - ' + rootCause : ''}`, code: 'api_failed' };
}
}
@@ -237,12 +271,13 @@ function groupBy(events, field) {
// Briefing — last 7 days of global conflict events
export async function briefing() {
if (!process.env.ACLED_EMAIL || !process.env.ACLED_PASSWORD) {
const { email, password } = acledCredentials();
if (!email || !password) {
return {
source: 'ACLED',
timestamp: new Date().toISOString(),
status: 'no_credentials',
message: 'Set ACLED_EMAIL and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
message: 'Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
};
}
@@ -256,7 +291,8 @@ export async function briefing() {
});
if (data?.error) {
return { source: 'ACLED', timestamp: new Date().toISOString(), error: data.error };
const status = data.code === 'auth_denied' ? 'auth_failed' : 'api_failed';
return { source: 'ACLED', timestamp: new Date().toISOString(), status, error: data.error };
}
let events = data?.data || [];
@@ -300,6 +336,7 @@ export async function briefing() {
return {
source: 'ACLED',
timestamp: new Date().toISOString(),
status: 'live',
period: { start, end },
totalEvents: events.length,
totalFatalities,
@@ -307,6 +344,7 @@ export async function briefing() {
byType,
topCountries,
deadliestEvents,
message: events.length === 0 ? 'ACLED returned a valid empty event set for the requested period.' : undefined,
};
}