1 Commits

Author SHA1 Message Date
MrSphay
5b176851c8 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
2026-05-17 14:03:18 +02:00
11 changed files with 206 additions and 778 deletions

View File

@@ -6,10 +6,7 @@ PORT=3117
REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
# LLM layer

View File

@@ -38,12 +38,7 @@ jobs:
run: docker compose config
- name: Build Docker image
shell: bash
run: |
image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
build_tag="build-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_NUMBER:-0}"
echo "BUILD_IMAGE=${image}:${build_tag}" >> "$GITHUB_ENV"
docker build -t "${image}:${build_tag}" .
run: docker build -t "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}:${GITHUB_SHA}" .
- name: Publish Docker image
if: ${{ env.REGISTRY_TOKEN != '' }}
@@ -52,9 +47,8 @@ jobs:
image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
date_tag="$(date -u +%Y%m%d)"
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin
docker tag "${BUILD_IMAGE}" "${image}:${GITHUB_SHA}"
docker tag "${BUILD_IMAGE}" "${image}:latest"
docker tag "${BUILD_IMAGE}" "${image}:${date_tag}"
docker tag "${image}:${GITHUB_SHA}" "${image}:latest"
docker tag "${image}:${GITHUB_SHA}" "${image}:${date_tag}"
docker push "${image}:${GITHUB_SHA}"
docker push "${image}:latest"
docker push "${image}:${date_tag}"

View File

@@ -136,9 +136,6 @@ REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
SWEEP_TOKEN=
TERMINAL_ACTIONS_ENABLED=true
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
LLM_PROVIDER=openrouter
@@ -190,21 +187,6 @@ LLM_MODEL=your-model
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
#### Terminal Action Exposure
`POST /api/action` and `POST /api/sweep` can trigger operational actions such as manual sweeps. The dashboard has a **SET TOKEN** control that stores your `SWEEP_TOKEN` in browser local storage and sends it as the `x-crucix-token` header; do not put action tokens in URLs.
Recommended settings:
| Deployment | Settings |
| --- | --- |
| Private local machine | `NODE_ENV=development`, optional `SWEEP_TOKEN`, optional `TERMINAL_ACTIONS_ENABLED=true`. Localhost can run actions without a token for development. |
| Private LAN / Dockge | Set a strong `SWEEP_TOKEN`, keep `TERMINAL_ACTIONS_ENABLED=true`, expose only to trusted clients. |
| Pangolin-authenticated reverse proxy | Set a strong `SWEEP_TOKEN`, keep Pangolin auth in front, use the dashboard **SET TOKEN** flow once per browser. |
| Public internet | Do not expose Terminal Actions directly. If exposure is unavoidable, require `SWEEP_TOKEN`, keep proxy authentication enabled, lower `TERMINAL_ACTION_RATE_LIMIT_MAX`, and monitor server audit logs. |
Action endpoints reject cross-origin POST origins, apply a small in-memory per-IP rate limit, and write sanitized audit lines without logging the token.
#### Build And Publish Your Gitea Image
```bash

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,
};
}

View File

@@ -24,9 +24,6 @@ export default {
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
sweepToken: process.env.SWEEP_TOKEN || null,
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok

View File

@@ -83,13 +83,6 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.sensor-actions{display:flex;gap:6px;align-items:center}
.mini-btn{border:1px solid rgba(100,240,200,0.18);background:rgba(100,240,200,0.04);color:var(--dim);font-family:var(--mono);font-size:9px;padding:3px 6px;cursor:pointer}
.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)}
.action-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:10px}
.action-btn{border:1px solid rgba(68,204,255,0.24);background:rgba(68,204,255,0.06);color:var(--text);font-family:var(--mono);font-size:9px;padding:7px 6px;cursor:pointer;text-transform:uppercase;letter-spacing:.08em}
.action-btn:hover{border-color:rgba(68,204,255,0.55);color:var(--accent2);background:rgba(68,204,255,0.12)}
.action-btn[disabled]{opacity:.45;cursor:wait}
.terminal-output{min-height:58px;max-height:180px;overflow:auto;border:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.22);padding:8px;font-family:var(--mono);font-size:10px;line-height:1.45;color:var(--dim);white-space:pre-wrap}
.terminal-output strong{color:var(--accent)}
.terminal-output .err{color:var(--danger)}
.layer-left{display:flex;align-items:center;gap:8px}
.ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)}
@@ -411,11 +404,8 @@ let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat();
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
let terminalOutput = 'Ready. Live data is loaded from /api/data in server mode.';
let terminalBusy = false;
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const terminalActionTokenKey = 'crucix_sweep_token';
const layerTypeMap = {
air: ['air'],
@@ -616,7 +606,6 @@ function renderTopbar(){
const ts = new Date(D.meta.timestamp);
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
const hasActionToken = !!getTerminalActionToken();
document.getElementById('topbar').innerHTML=`
<div class="top-left">
<span class="brand">CRUCIX MONITOR</span>
@@ -629,26 +618,12 @@ function renderTopbar(){
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'&#x25B2; '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'&#x25BC; '+t('dashboard.riskOn','RISK-ON'):'&#x25C6; '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
<button class="guide-btn" onclick="configureTerminalActionToken()" title="Configure SWEEP_TOKEN for protected terminal actions">${hasActionToken?'TOKEN SET':'SET TOKEN'}</button>
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
</div>`;
renderRegionControls();
}
function getTerminalActionToken(){
return localStorage.getItem(terminalActionTokenKey) || localStorage.getItem('crucix_terminal_action_token') || '';
}
function configureTerminalActionToken(){
const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken());
if(next === null) return;
const clean = next.trim();
if(clean) localStorage.setItem(terminalActionTokenKey, clean);
else localStorage.removeItem(terminalActionTokenKey);
renderTopbar();
}
// === LEFT RAIL ===
function layerMode(key){ return layerModes[key] || 'normal'; }
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
@@ -1589,52 +1564,6 @@ function renderLower(){
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`;
}
async function runTerminalAction(action){
if(terminalBusy) return;
let token = getTerminalActionToken();
if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){
configureTerminalActionToken();
token = getTerminalActionToken();
if(!token) return;
}
terminalBusy = true;
terminalOutput = `> ${action}\nRunning...`;
renderRight();
try{
const res = await fetch('/api/action', {
method:'POST',
headers:{
'Content-Type':'application/json',
...(token ? {'x-crucix-token': token} : {})
},
body:JSON.stringify({action})
});
const payload = await res.json().catch(()=>({error:'Invalid server response'}));
if(!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
if(action === 'status'){
const h = payload.health || {};
terminalOutput = [
'> status',
`State: ${h.status || '--'}`,
`Last sweep: ${h.lastSuccessfulSweep || h.lastSweep || '--'}`,
`Data age: ${h.dataAgeSeconds != null ? h.dataAgeSeconds + 's' : '--'}`,
`Sources: ${h.sourcesOk || 0} ok / ${h.sourcesDegraded || 0} degraded / ${h.sourcesFailed || 0} failed`,
`LLM: ${h.llm?.state || '--'}`,
`Sweep active: ${h.sweepInProgress ? 'yes' : 'no'}`
].join('\n');
}else if(action === 'brief'){
terminalOutput = `> brief\n${payload.text || 'No briefing text returned.'}`;
}else if(action === 'sweep'){
terminalOutput = `> sweep\n${payload.status === 'already_running' ? 'Sweep already running.' : 'Sweep accepted. The dashboard will update when the sweep finishes.'}`;
}
}catch(err){
terminalOutput = `> ${action}\nERROR: ${err.message}`;
}finally{
terminalBusy = false;
renderRight();
}
}
// === RIGHT RAIL ===
function renderRight(){
const mobile = isMobileLayout();
@@ -1676,16 +1605,6 @@ function renderRight(){
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
document.getElementById('rightRail').innerHTML=`
<div class="g-panel right-actions">
<div class="sec-head"><h3>Terminal Actions</h3><span class="badge">${terminalBusy?'RUNNING':'READY'}</span></div>
<div class="action-grid">
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('status')">Status</button>
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('sweep')">Sweep</button>
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('brief')">Brief</button>
</div>
<button class="mini-btn" style="margin-bottom:8px" onclick="configureTerminalActionToken()">Configure token</button>
<div class="terminal-output">${terminalOutput.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])).replace(/\n/g,'<br>')}</div>
</div>
<div class="g-panel right-signals">
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
${signals}
@@ -1920,10 +1839,10 @@ document.addEventListener('DOMContentLoaded', () => {
const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
if (canProbeApi) {
if (canProbeApi && !hasInlineData) {
// Server mode: always fetch live data from API (ignore any stale inline D)
fetch('/api/data')
.then(r => { if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(r => r.json())
.then(data => { D = data; init(); connectSSE(); })
.catch(() => {
// Should not reach here — server routes to loading.html when no data

View File

@@ -1,489 +1,18 @@
# Agent Handoff
Last updated: 2026-05-17
## Current Release Goal
## Repository State
Source branch: `codex/production-intelligence-terminal`
Project: Crucix fork / Intelligence Terminal
Local workspace:
```text
C:\Users\MrSphay\Documents\Codex\Crucix\intelligence-terminal
```
Remotes:
```text
origin https://git.wilkensxl.de/MrSphay/intelligence-terminal.git
upstream https://github.com/calesthio/Crucix.git
```
Current branch tip:
```text
Run `git rev-parse HEAD` after clone/pull. This handoff was updated by the `docs: sync issue tracker and handoff` commit after the implementation commit below.
```
Latest implementation commit before issue-sync documentation:
```text
53470cc701ec322080a89d220aef449b25850590
```
Both pushed branches currently point to this commit:
```text
origin/codex/production-intelligence-terminal
origin/main
```
Gitea repository:
```text
https://git.wilkensxl.de/MrSphay/intelligence-terminal
```
Default branch observed through the Gitea API:
```text
codex/production-intelligence-terminal
```
## Agent Kit Requirements Applied
The mandatory kit was cloned and reviewed first:
```text
C:\Users\MrSphay\Documents\Codex\Crucix\agent-kit
```
Rules applied from the kit:
- Keep agent context in source control: `AGENTS.md`, `.codex/project.md`, and this handoff file.
- Use Gitea Ubuntu runners for heavy verification and package publishing.
- Keep Docker/Dockge operation first-class.
- Do not commit secrets, `.env`, private logs, tokens, or generated `runs/` data.
- Add report-only maintenance workflows for security, dependency checks, repo cleanup, release dry runs, and template compliance.
- Poll pushed Gitea Actions until terminal state when a token is available.
## What Was Implemented
### Docker And Runtime
- Docker image is Docker-first and Dockge/Pangolin suitable.
- Browser auto-open is disabled by default through `AUTO_OPEN_BROWSER=false`.
- Runtime health checks now work in the container without `wget` or host browser tools.
- `runs` is persisted through a volume.
- A later fix added `docker-entrypoint.sh` to prepare `/app/runs` before dropping privileges, so mounted volumes work with the non-root Node runtime.
- `docker-compose.yml` uses the Gitea Registry image by default:
Registry image:
```text
git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
### API And Health
## Notes
Added or hardened:
- `GET /api/health`
- `GET /api/data`
- `GET /api/metrics`
- `POST /api/sweep`
- `POST /api/action`
Health now reports:
- `starting`
- `healthy`
- `degraded`
- `stale`
- `error`
It also reports:
- last sweep timestamps
- stale/bootstrap state
- data age
- source health
- source errors
- LLM configuration state
- Telegram/Discord enabled state
- memory store state
### Live Data And Source Degradation
- Existing `runs/latest.json` is only treated as bootstrap/stale data until a real sweep completes.
- Sweeps update `sourceHealth`, SSE/API data, and memory state.
- RSS/news feed failures no longer silently look like fresh valid data.
- `safeFetch` now tracks request counts, failures, bytes, source labels, hosts, and recent fetch events.
- `safeFetch` has better timeout/retry/backoff/error behavior and reports HTML-as-API-error cases.
- Yahoo Finance fetches are more explicit about source errors and HTML/API failures.
- ACLED missing credentials now degrade transparently.
- Telegram polling has quieter network-error backoff logs.
### LLM Integration
Added unified OpenAI-compatible provider layer:
```text
lib/llm/openai-compatible.mjs
```
Supported provider paths include:
- `openrouter`
- `openai`
- `openai-compatible`
- `local-openai`
- `lmstudio`
- `lm-studio`
- `ollama`
Relevant environment keys:
```text
LLM_PROVIDER
LLM_BASE_URL
LLM_API_KEY
LLM_MODEL
LLM_TEMPERATURE
LLM_MAX_TOKENS
LLM_TIMEOUT_MS
OPENROUTER_SITE_URL
OPENROUTER_APP_NAME
```
OpenRouter Free and local OpenAI-compatible endpoints are documented in `README.md` and `.env.example`.
### Memory
Added Phase-1 SQLite memory:
```text
lib/intelligence-store.mjs
runs/intelligence.db
```
It uses `node:sqlite` when available and gracefully falls back when unavailable.
### Dashboard
Implemented:
- interactive Sensor Grid layer modes
- focus/hide/normal states persisted in `localStorage`
- Space Watch icon/orbit toggle
- map/globe filtering consistency
- flat map label redraw handling
- live server-mode data loading from `/api/data` even when `jarvis.html` still contains an offline inline snapshot
- Terminal Actions panel with `Status`, `Sweep`, and `Brief` buttons
Important UI markers in the final code:
```text
layerModes
spaceDisplayMode
toggleSpaceDisplay()
shouldShowType()
runTerminalAction()
```
### Briefings
Brief output now includes:
- Source Integrity
- evidence links
- event IDs
- configurable verbosity through `BRIEF_VERBOSITY`
### Documentation
Updated:
- `README.md`
- `.env.example`
- `docs/sources/README.md`
- `docs/sources/opensky.md`
- `docs/sources/acled.md`
- `docs/sources/telegram.md`
- `docs/sources/firms.md`
- `docs/sources/maritime.md`
- `docs/security-review.md`
- `docs/release-checklist.md`
README includes:
- Gitea Registry pull example
- Dockge-compatible compose example
- full `.env` examples
- OpenRouter Free setup
- LM Studio setup
- Ollama setup
- local OpenAI-compatible setup
- Pangolin/reverse proxy notes
## Registry And Images
Registry image:
```text
git.wilkensxl.de/mrsphay/intelligence-terminal
```
Verified package tags through Gitea API:
```text
latest
20260517
e933586b220656a2858d2215b934b22d1f08a908
53470cc701ec322080a89d220aef449b25850590
```
Successful pull test:
```bash
docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
Observed digest:
```text
sha256:780a41413921bd9a676461eca1cd1372591f523be4b7c9513d9bc085cbe7922d
```
## Gitea Actions
Workflows present:
```text
.gitea/workflows/build.yml
.gitea/workflows/security-scan.yml
.gitea/workflows/repo-cleanup.yml
.gitea/workflows/dependency-check.yml
.gitea/workflows/release-dry-run.yml
.gitea/workflows/template-compliance.yml
```
Final runs for commit `53470cc701ec322080a89d220aef449b25850590` were polled through the Gitea API and succeeded:
```text
build.yml on main: success
build.yml on codex/production-intelligence-terminal: success
release-dry-run.yml on main: success
release-dry-run.yml on codex/production-intelligence-terminal: success
template-compliance.yml on main: success
template-compliance.yml on codex/production-intelligence-terminal: success
```
Relevant run URLs:
```text
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/23
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/24
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/25
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/26
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/27
https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/28
```
Repository secret expected by the registry publish workflow:
```text
REGISTRY_TOKEN
```
Local token note:
- `GITEA_TOKEN` was visible in the final Codex process.
- It was used only for Gitea API checks and not printed.
## Issue Sync
Open upstream GitHub issues were reviewed on 2026-05-17 from:
```text
https://github.com/calesthio/Crucix/issues
```
The upstream list contained 24 open issues. Issues already handled by this fork were not copied as open work, including the Docker stale-dashboard incident (#105), map label redraw (#70), Sensor Grid controls (#72), space display toggle (#51), source docs (#52), Dockge/CasaOS docs (#78), LLM timeout (#87), inject/static helper confusion (#100), network metrics (#101), Telegram polling backoff (#104), and briefing/evidence context (#75).
Issues not relevant to this fork were also not copied, including the Wallpaper Engine redesign (#41), the fork-inflation discussion (#107), empty/unclear placeholders (#79/#80), and the general use-case discussion (#93).
The following Gitea issues were created for real remaining work:
```text
#1 Reddit source must stop unauthenticated .json scraping
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/1
#2 Send operator alerts when dashboard data remains stale
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/2
#3 ACLED credentialed integration needs regression test and diagnostics
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/3
#4 Complete memory and prediction loop beyond Phase-1 SQLite
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/4
#5 Remove old inline dashboard snapshot from production builds
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/5
#6 Harden Terminal Actions for public reverse-proxy deployments
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/6
#7 Replace ADS-B stub with real disabled/degraded source handling
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/7
#8 Clean inherited public-demo and upstream marketing references
https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/8
```
## Verification Already Performed
Local lightweight checks:
```bash
npm run test:unit
npm audit --omit=dev --audit-level=high
docker compose --env-file .env.example config
node --check server.mjs
node --check dashboard/inject.mjs
node --check lib/llm/openai-compatible.mjs
git diff --check
```
Unit test result:
```text
21 tests passing
0 failing
```
Audit result:
```text
0 high vulnerabilities
```
Docker build and smoke test were performed locally earlier:
```bash
docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:latest .
docker run --rm -d --name intelligence-terminal-smoke -p 127.0.0.1::3117 -e AUTO_OPEN_BROWSER=false git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
Smoke test observations:
- Server booted.
- No `xdg-open` error.
- Initial sweep completed.
- `/api/health` moved from `starting` to `degraded` with transparent source errors.
- Degraded state was expected without all optional API keys.
Additional checks after fixing the dashboard live-data bug and Terminal Actions:
```bash
node --check server.mjs
npm run test:unit
docker compose --env-file .env.example config
git diff --check
```
The dashboard script was also syntax-checked after extracting script blocks from `dashboard/public/jarvis.html`.
## Important Commits
```text
7e85a54 chore: apply agent kit project structure
85f97bb feat: harden intelligence runtime and llm providers
42b7fc2 docs: add registry dockge and dashboard operations
d072390 ci: align gitea workflows with agent kit
0559481 ci: fix gitea registry publish login
f3c9331 ci: fix agent kit compliance checks
c2d572e fix: prepare runs volume before dropping privileges
8e096b2 ci: harden gitea workflow reruns
e933586 merge: reconcile main with production branch
4262c7e docs: expand agent handoff
53470cc fix: load live dashboard data and add terminal actions
```
The large implementation commit `85f97bb` and the dashboard/action fix `53470cc` are contained in both:
```text
origin/codex/production-intelligence-terminal
origin/main
```
## How To Continue In A Fresh Codex Environment
1. Clone the Gitea repository:
```bash
git clone https://git.wilkensxl.de/MrSphay/intelligence-terminal.git
cd intelligence-terminal
git checkout codex/production-intelligence-terminal
```
2. Confirm the expected commit:
```bash
git rev-parse HEAD
```
Expected:
```text
The branch tip should include commit 53470cc701ec322080a89d220aef449b25850590 and the later `docs: sync issue tracker and handoff` commit.
```
3. Read these files first:
```text
AGENTS.md
.codex/project.md
docs/agent-handoff.md
README.md
.env.example
```
4. If checking Actions, use `GITEA_TOKEN` from the environment. Do not print it.
PowerShell check:
```powershell
if ($env:GITEA_TOKEN) { "GITEA_TOKEN=set" } else { "GITEA_TOKEN=missing" }
```
5. Useful commands:
```bash
npm run test:unit
docker compose --env-file .env.example config
docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
6. Start with Dockge/Pangolin using the README compose example and a `.env` based on `.env.example`.
## Remaining Risks And Follow-Ups
- Some sources will report `degraded` until optional keys are set, especially ACLED, FRED, EIA, and Cloudflare Radar.
- OpenSky can rate-limit with HTTP 429; this is now visible in health instead of hidden.
- GDELT/OFAC can time out under runner/network conditions; health reports this explicitly.
- Browser-level visual verification of the full dashboard should be repeated after any future UI change.
- The project still inherits the original Crucix broad source surface. Future work should prefer focused source-by-source tests over broad refactors.
- If a new Codex environment sees non-fast-forward branch pushes, fetch first and preserve remote commits. Do not force-push without explicit approval.
## Operator Pull Command
For deployment:
```bash
docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
For a pinned deployment:
```bash
docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:20260517
```
- The repository is Docker-first and should stay suitable for Dockge/Pangolin.
- Use `.env.example` as the operator-facing source of truth for configuration.
- Source health and network metrics are available through `/api/health` and `/api/metrics`.
- If Gitea Registry authentication is unavailable locally, build and push with the commands documented in `README.md`.

View File

@@ -5,21 +5,12 @@
- Shell execution: browser auto-open is gated by `AUTO_OPEN_BROWSER` and defaults to false.
- Secrets: `.env` remains ignored; `.env.example` contains no real keys.
- External network calls: source fetches use timeout/retry diagnostics and expose degraded state.
- Manual actions: `/api/sweep` and `/api/action` are gated by `TERMINAL_ACTIONS_ENABLED` and local-only or `SWEEP_TOKEN` authorization.
- Manual actions: `/api/sweep` is local-only unless `SWEEP_TOKEN` is configured.
- File writes: runtime writes are limited to `runs/`.
- HTML injection: dashboard data is JSON-injected only by the CLI path; server mode serves data through API/SSE.
## Terminal Actions
- `TERMINAL_ACTIONS_ENABLED=true` enables dashboard-triggered `status`, `sweep`, and `brief` actions through `POST /api/action`.
- If `SWEEP_TOKEN` is set, callers must send the token through `x-sweep-token`, `Authorization: Bearer ...`, or the `token` request body field.
- If `SWEEP_TOKEN` is empty, actions are accepted only from local loopback addresses.
- For private Dockge/LAN deployments, this is intended to make the terminal operable from the browser.
- For Pangolin or other internet-exposed deployments, set `SWEEP_TOKEN` or `TERMINAL_ACTIONS_ENABLED=false` until the public reverse-proxy hardening issue is completed.
## Residual Risk
- External feeds can return malformed, stale, or adversarial content. UI rendering should continue to sanitize titles and URLs.
- LLM outputs are advisory only and must not be treated as financial advice.
- `node:sqlite` availability depends on the Node 22 build; when unavailable the memory database degrades to a no-op placeholder.
- Browser-stored sweep tokens are acceptable for a trusted home-server UI, but should not be treated as a strong auth boundary on a public endpoint.

View File

@@ -2,8 +2,9 @@
Provides conflict events, fatalities, event types, and locations.
- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`.
- Auth: `ACLED_EMAIL` or `ACLED_USER`, plus `ACLED_PASSWORD`.
- Flow: OAuth password grant is tried first, then cookie session fallback.
- Failure modes: missing credentials, terms/profile not completed, expired token, account missing API access.
- 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`).
- 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`.

View File

@@ -39,7 +39,6 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
let sweepInProgress = false;
const startTime = Date.now();
const sseClients = new Set();
const terminalActionBuckets = new Map();
// === Delta/Memory ===
const memory = new MemoryManager(RUNS_DIR);
@@ -290,35 +289,14 @@ app.get('/api/metrics', (req, res) => {
});
app.post('/api/sweep', express.json(), (req, res) => {
const guard = authorizeTerminalAction(req, res, 'sweep');
if (!guard.ok) return;
triggerSweepAction(req, res, 'sweep');
});
app.post('/api/action', express.json(), (req, res) => {
const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase();
const guard = authorizeTerminalAction(req, res, action || 'unknown');
if (!guard.ok) return;
if (action === 'status') {
auditTerminalAction(req, 'status', 'ok');
return res.json({ ok: true, action, status: 'ok', health: buildHealth() });
}
if (action === 'brief') {
if (!currentData) {
auditTerminalAction(req, 'brief', 'rejected', 'no_data');
return res.status(503).json({ ok: false, action, error: 'No data yet - first sweep in progress' });
}
auditTerminalAction(req, 'brief', 'ok');
const brief = buildBrief(currentData);
return res.json({ ok: true, action, status: 'ok', brief, text: brief });
}
if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action');
return res.status(400).json({ ok: false, error: 'Unknown action', allowed: ['status', 'brief', 'sweep'], actions: ['status', 'brief', 'sweep'] });
const remote = req.ip || '';
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
if (config.sweepToken && token !== config.sweepToken) return res.status(401).json({ error: 'Invalid sweep token' });
if (!config.sweepToken && !local) return res.status(403).json({ error: 'Manual sweep is local-only unless SWEEP_TOKEN is set' });
if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt });
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
res.status(202).json({ status: 'accepted' });
});
// API: available locales
@@ -349,108 +327,6 @@ function broadcast(data) {
}
}
function requestIp(req) {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
function isLocalRequest(req) {
const remote = requestIp(req);
return remote === '::1'
|| remote === '127.0.0.1'
|| remote === '::ffff:127.0.0.1'
|| remote.startsWith('127.')
|| remote === 'localhost';
}
function sameOriginPost(req) {
const origin = req.get('origin');
if (!origin) return true;
try {
const originUrl = new URL(origin);
const host = req.get('host');
return host && originUrl.host === host;
} catch {
return false;
}
}
function actionToken(req) {
return req.get('x-crucix-token') || req.body?.token || null;
}
function auditTerminalAction(req, action, outcome, detail = null) {
const suffix = detail ? ` detail=${detail}` : '';
console.log(`[Crucix][audit] terminal_action action=${action || 'unknown'} outcome=${outcome} ip=${requestIp(req)}${suffix}`);
}
function rateLimitTerminalAction(req, action) {
const now = Date.now();
const windowMs = Math.max(1000, config.terminalActionRateLimitWindowMs || 60_000);
const max = Math.max(1, config.terminalActionRateLimitMax || 10);
const key = `${requestIp(req)}:${action}`;
const bucket = terminalActionBuckets.get(key);
if (!bucket || now > bucket.resetAt) {
terminalActionBuckets.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true };
}
bucket.count += 1;
if (bucket.count > max) {
return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { ok: true };
}
function authorizeTerminalAction(req, res, action) {
const rate = rateLimitTerminalAction(req, action);
if (!rate.ok) {
auditTerminalAction(req, action, 'rejected', 'rate_limited');
res.set('Retry-After', String(rate.retryAfterSeconds));
res.status(429).json({ error: 'Too many terminal actions', retryAfterSeconds: rate.retryAfterSeconds });
return { ok: false };
}
if (!sameOriginPost(req)) {
auditTerminalAction(req, action, 'rejected', 'csrf_origin');
res.status(403).json({ error: 'Origin mismatch' });
return { ok: false };
}
const local = isLocalRequest(req);
const token = actionToken(req);
if (!config.terminalActionsEnabled) {
auditTerminalAction(req, action, 'rejected', 'disabled');
res.status(403).json({ error: 'Terminal actions are disabled' });
return { ok: false };
}
if (config.sweepToken) {
if (token !== config.sweepToken) {
auditTerminalAction(req, action, 'rejected', 'invalid_token');
res.status(401).json({ error: 'Invalid terminal action token' });
return { ok: false };
}
return { ok: true };
}
if (!local) {
auditTerminalAction(req, action, 'rejected', 'missing_token');
res.status(403).json({ error: 'Terminal actions are local-only unless SWEEP_TOKEN is set' });
return { ok: false };
}
return { ok: true };
}
function triggerSweepAction(req, res, auditAction) {
if (sweepInProgress) {
auditTerminalAction(req, auditAction, 'rejected', 'already_running');
return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
}
auditTerminalAction(req, auditAction, 'accepted');
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
return res.status(202).json({ ok: true, status: 'accepted' });
}
function dataAgeMs() {
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
@@ -500,8 +376,6 @@ function buildHealth() {
llm: getLLMStatus(),
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
terminalActionsEnabled: config.terminalActionsEnabled,
terminalActionsTokenRequired: !!config.sweepToken,
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
memory: intelligenceStore.status(),

View File

@@ -1,6 +1,5 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
@@ -36,21 +35,128 @@ test('safeFetchText returns text and byte count', async () => {
}
});
test('terminal action endpoints avoid URL tokens and include hardening gates', () => {
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
assert.match(server, /app\.post\('\/api\/action'/);
assert.match(server, /app\.post\('\/api\/sweep'/);
assert.match(server, /x-crucix-token/);
assert.match(server, /sameOriginPost/);
assert.match(server, /rateLimitTerminalAction/);
assert.match(server, /auditTerminalAction/);
assert.doesNotMatch(server, /req\.query\.token/);
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('dashboard exposes token configuration flow without devtools edits', () => {
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
assert.match(html, /configureTerminalActionToken/);
assert.match(html, /crucix_sweep_token/);
assert.match(html, /x-crucix-token/);
assert.match(html, /SET TOKEN/);
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/);
});
});