Compare commits
1 Commits
codex/issu
...
79f897f8ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f897f8ac |
@@ -7,6 +7,9 @@ REFRESH_INTERVAL_MINUTES=15
|
|||||||
AUTO_OPEN_BROWSER=false
|
AUTO_OPEN_BROWSER=false
|
||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
# LLM layer
|
# LLM layer
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -136,6 +136,9 @@ REFRESH_INTERVAL_MINUTES=15
|
|||||||
AUTO_OPEN_BROWSER=false
|
AUTO_OPEN_BROWSER=false
|
||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
LLM_PROVIDER=openrouter
|
LLM_PROVIDER=openrouter
|
||||||
@@ -187,6 +190,21 @@ 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`.
|
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
|
#### Build And Publish Your Gitea Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default {
|
|||||||
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
||||||
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
||||||
sweepToken: process.env.SWEEP_TOKEN || null,
|
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: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
|
|||||||
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
|
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
|
||||||
let currentRegion = 'world';
|
let currentRegion = 'world';
|
||||||
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
||||||
|
const terminalActionTokenKey = 'crucix_terminal_action_token';
|
||||||
|
|
||||||
const layerTypeMap = {
|
const layerTypeMap = {
|
||||||
air: ['air'],
|
air: ['air'],
|
||||||
@@ -606,6 +607,7 @@ function renderTopbar(){
|
|||||||
const ts = new Date(D.meta.timestamp);
|
const ts = new Date(D.meta.timestamp);
|
||||||
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
|
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 timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
|
||||||
|
const hasActionToken = !!getTerminalActionToken();
|
||||||
document.getElementById('topbar').innerHTML=`
|
document.getElementById('topbar').innerHTML=`
|
||||||
<div class="top-left">
|
<div class="top-left">
|
||||||
<span class="brand">CRUCIX MONITOR</span>
|
<span class="brand">CRUCIX MONITOR</span>
|
||||||
@@ -618,12 +620,56 @@ function renderTopbar(){
|
|||||||
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
|
<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>
|
<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'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
|
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+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="runTerminalAction('status')" title="Check protected terminal action access">STATUS</button>
|
||||||
|
<button class="guide-btn" onclick="runTerminalAction('sweep')" title="Trigger a protected intelligence sweep">SWEEP</button>
|
||||||
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
|
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
|
||||||
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
|
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderRegionControls();
|
renderRegionControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTerminalActionToken(){
|
||||||
|
return localStorage.getItem(terminalActionTokenKey) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTerminalAction(action){
|
||||||
|
let token = getTerminalActionToken();
|
||||||
|
if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){
|
||||||
|
configureTerminalActionToken();
|
||||||
|
token = getTerminalActionToken();
|
||||||
|
if(!token) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {'Content-Type':'application/json'};
|
||||||
|
if(token) headers['x-crucix-token'] = token;
|
||||||
|
try{
|
||||||
|
const response = await fetch('/api/action', {
|
||||||
|
method:'POST',
|
||||||
|
headers,
|
||||||
|
body:JSON.stringify({action})
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if(!response.ok) throw new Error(payload.error || `Action failed (${response.status})`);
|
||||||
|
if(action === 'status'){
|
||||||
|
window.alert(`Terminal actions OK. Health: ${payload.health?.status || 'unknown'}`);
|
||||||
|
} else if(action === 'sweep'){
|
||||||
|
window.alert(payload.status === 'accepted' ? 'Sweep accepted.' : `Sweep status: ${payload.status || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
window.alert(`Terminal action failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === LEFT RAIL ===
|
// === LEFT RAIL ===
|
||||||
function layerMode(key){ return layerModes[key] || 'normal'; }
|
function layerMode(key){ return layerModes[key] || 'normal'; }
|
||||||
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
|
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
141
server.mjs
141
server.mjs
@@ -39,6 +39,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
|
|||||||
let sweepInProgress = false;
|
let sweepInProgress = false;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const sseClients = new Set();
|
const sseClients = new Set();
|
||||||
|
const terminalActionBuckets = new Map();
|
||||||
|
|
||||||
// === Delta/Memory ===
|
// === Delta/Memory ===
|
||||||
const memory = new MemoryManager(RUNS_DIR);
|
const memory = new MemoryManager(RUNS_DIR);
|
||||||
@@ -289,14 +290,34 @@ app.get('/api/metrics', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sweep', express.json(), (req, res) => {
|
app.post('/api/sweep', express.json(), (req, res) => {
|
||||||
const remote = req.ip || '';
|
const guard = authorizeTerminalAction(req, res, 'sweep');
|
||||||
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
|
if (!guard.ok) return;
|
||||||
const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
|
triggerSweepAction(req, res, 'sweep');
|
||||||
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 });
|
app.post('/api/action', express.json(), (req, res) => {
|
||||||
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase();
|
||||||
res.status(202).json({ status: 'accepted' });
|
const guard = authorizeTerminalAction(req, res, action || 'unknown');
|
||||||
|
if (!guard.ok) return;
|
||||||
|
|
||||||
|
if (action === 'status') {
|
||||||
|
auditTerminalAction(req, 'status', 'ok');
|
||||||
|
return res.json({ status: 'ok', health: buildHealth() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'brief') {
|
||||||
|
if (!currentData) {
|
||||||
|
auditTerminalAction(req, 'brief', 'rejected', 'no_data');
|
||||||
|
return res.status(503).json({ error: 'No data yet - first sweep in progress' });
|
||||||
|
}
|
||||||
|
auditTerminalAction(req, 'brief', 'ok');
|
||||||
|
return res.json({ status: 'ok', brief: buildBrief(currentData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
|
||||||
|
|
||||||
|
auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action');
|
||||||
|
return res.status(400).json({ error: 'Unknown action', allowed: ['status', 'brief', 'sweep'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: available locales
|
// API: available locales
|
||||||
@@ -327,6 +348,108 @@ 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({ 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({ status: 'accepted' });
|
||||||
|
}
|
||||||
|
|
||||||
function dataAgeMs() {
|
function dataAgeMs() {
|
||||||
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
||||||
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
||||||
@@ -376,6 +499,8 @@ function buildHealth() {
|
|||||||
llm: getLLMStatus(),
|
llm: getLLMStatus(),
|
||||||
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
||||||
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
||||||
|
terminalActionsEnabled: config.terminalActionsEnabled,
|
||||||
|
terminalActionsTokenRequired: !!config.sweepToken,
|
||||||
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
||||||
language: currentLanguage,
|
language: currentLanguage,
|
||||||
memory: intelligenceStore.status(),
|
memory: intelligenceStore.status(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
||||||
|
|
||||||
test('safeFetch reports HTML as degraded JSON response', async () => {
|
test('safeFetch reports HTML as degraded JSON response', async () => {
|
||||||
@@ -35,128 +36,21 @@ test('safeFetchText returns text and byte count', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(payload, ok = true, status = 200) {
|
test('terminal action endpoints avoid URL tokens and include hardening gates', () => {
|
||||||
return {
|
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||||
ok,
|
assert.match(server, /app\.post\('\/api\/action'/);
|
||||||
status,
|
assert.match(server, /app\.post\('\/api\/sweep'/);
|
||||||
headers: { getSetCookie: () => [], get: () => 'application/json' },
|
assert.match(server, /x-crucix-token/);
|
||||||
text: async () => JSON.stringify(payload),
|
assert.match(server, /sameOriginPost/);
|
||||||
json: async () => payload,
|
assert.match(server, /rateLimitTerminalAction/);
|
||||||
};
|
assert.match(server, /auditTerminalAction/);
|
||||||
}
|
assert.doesNotMatch(server, /req\.query\.token/);
|
||||||
|
|
||||||
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 () => {
|
test('dashboard exposes token configuration flow without devtools edits', () => {
|
||||||
const responses = [
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
textResponse('invalid credentials', false, 401),
|
assert.match(html, /configureTerminalActionToken/);
|
||||||
textResponse('forbidden', false, 403),
|
assert.match(html, /crucix_terminal_action_token/);
|
||||||
];
|
assert.match(html, /x-crucix-token/);
|
||||||
|
assert.match(html, /SET TOKEN/);
|
||||||
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