${t('panels.signalCore','Signal Core')}
${t('badges.hotMetrics','HOT METRICS')}
diff --git a/docs/sources/README.md b/docs/sources/README.md
index 008b1f5..e8549a4 100644
--- a/docs/sources/README.md
+++ b/docs/sources/README.md
@@ -16,3 +16,4 @@ Source docs:
- [Telegram](telegram.md)
- [FIRMS](firms.md)
- [Maritime](maritime.md)
+- [Reddit](reddit.md)
diff --git a/docs/sources/acled.md b/docs/sources/acled.md
index c6ba1fb..c04f23a 100644
--- a/docs/sources/acled.md
+++ b/docs/sources/acled.md
@@ -2,8 +2,11 @@
Provides conflict events, fatalities, event types, and locations.
-- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`.
+- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`. `ACLED_USER` or `ACLED_USERNAME` may be used as aliases for `ACLED_EMAIL`.
- 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.
-- Behavior: missing or rejected credentials produce degraded source health with the ACLED error text.
-- Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.
+- Failure modes are classified as `no_credentials`, `auth_failed`, `access_denied`, or `api_failed`.
+- Behavior: missing, rejected, or unauthorized credentials produce degraded source health with a concise operator message.
+- Secret handling: debug output never prints bearer tokens, cookies, or the configured password.
+- Test: run `node --test test/acled-source.test.mjs`; with real credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.
+
+`access_denied` normally means the login worked but the account cannot read API data. Check that ACLED terms are accepted, required profile fields are complete, and API access is enabled for the account.
diff --git a/docs/sources/opensky.md b/docs/sources/opensky.md
index 19ee63f..bd3334e 100644
--- a/docs/sources/opensky.md
+++ b/docs/sources/opensky.md
@@ -6,4 +6,4 @@ Provides public aircraft state data for regional air-activity hotspots.
- Failure modes: timeouts, `HTTP 429`, and empty regions.
- Behavior: source health is marked degraded on API errors. The dashboard may use the most recent non-empty air snapshot from `runs/` and marks it in `airMeta.fallback`.
- Test: start a sweep and inspect `/api/health` plus `airMeta` from `/api/data`.
-- Operator note: Crucix does not bypass OpenSky throttles. Increase `REFRESH_INTERVAL_MINUTES` if the source degrades repeatedly.
+- Operator note: Intelligence Terminal does not bypass OpenSky throttles. Increase `REFRESH_INTERVAL_MINUTES` if the source degrades repeatedly.
diff --git a/docs/sources/reddit.md b/docs/sources/reddit.md
new file mode 100644
index 0000000..c7ce6e4
--- /dev/null
+++ b/docs/sources/reddit.md
@@ -0,0 +1,33 @@
+# Reddit Source
+
+Reddit is used as a social sentiment input for selected geopolitical and market subreddits.
+
+## Configuration
+
+Create a Reddit script app at:
+
+```text
+https://www.reddit.com/prefs/apps/
+```
+
+Then set:
+
+```env
+REDDIT_CLIENT_ID=
+REDDIT_CLIENT_SECRET=
+```
+
+## Runtime Behavior
+
+- The source uses the OAuth client credentials flow and then reads `https://oauth.reddit.com`.
+- Unauthenticated `reddit.com/.../hot.json` scraping is intentionally disabled.
+- Missing credentials return `status: no_credentials` and are surfaced as source degradation.
+- OAuth failures return `status: auth_failed` without logging or returning the client secret.
+- Subreddit fetch failures return `status: degraded` with per-subreddit errors.
+
+## Test
+
+```bash
+node apis/sources/reddit.mjs
+npm run test:unit
+```
diff --git a/lib/scenarios.mjs b/lib/scenarios.mjs
new file mode 100644
index 0000000..16b86bd
--- /dev/null
+++ b/lib/scenarios.mjs
@@ -0,0 +1,212 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { join } from 'path';
+
+const DEFAULT_SCENARIOS = [
+ {
+ id: 'middle-east-energy-shock',
+ enabled: false,
+ name: 'Middle East energy shock',
+ description: 'Energy supply risk building from Middle East conflict or chokepoint pressure.',
+ regions: ['Middle East', 'Iran', 'Israel', 'Strait of Hormuz'],
+ categories: ['osint', 'energy', 'maritime'],
+ keywords: ['missile', 'strike', 'hormuz', 'oil', 'energy', 'blockade'],
+ thresholds: { watching: 2, building: 4, confirmed: 7 },
+ invalidation: 'WTI normalizes and regional urgent signals fade for several sweeps.',
+ },
+ {
+ id: 'macro-stress-spillover',
+ enabled: false,
+ name: 'Macro stress spillover',
+ description: 'Market stress spreads from volatility into credit, rates, or commodities.',
+ regions: ['US', 'Global'],
+ categories: ['macro', 'markets'],
+ keywords: ['vix', 'spread', 'credit', 'yield', 'inflation', 'gold'],
+ thresholds: { watching: 2, building: 4, confirmed: 6 },
+ invalidation: 'VIX and credit stress both normalize while source health remains stable.',
+ },
+ {
+ id: 'regional-escalation-risk',
+ enabled: false,
+ name: 'Regional escalation risk',
+ description: 'Local conflict signals broaden across adjacent regions or source categories.',
+ regions: ['Ukraine', 'Taiwan', 'Africa', 'Middle East'],
+ categories: ['conflict', 'thermal', 'osint', 'air'],
+ keywords: ['mobilization', 'intercept', 'drone', 'ballistic', 'fatalities', 'border'],
+ thresholds: { watching: 2, building: 5, confirmed: 8 },
+ invalidation: 'No fresh cross-source escalation signals appear inside the configured horizon.',
+ },
+];
+
+export function evaluateScenarios(data, delta, runsDir) {
+ const loaded = loadScenarioDefinitions(runsDir);
+ if (!loaded.ok) {
+ return { available: false, error: loaded.error, items: [], changed: [] };
+ }
+
+ const statePath = join(runsDir, 'scenario-state.json');
+ const previous = readJson(statePath, {});
+ const evaluatedAt = data.meta?.timestamp || new Date().toISOString();
+ const corpus = buildCorpus(data, delta);
+ const items = loaded.scenarios.map(def => evaluateScenario(def, corpus, previous[def.id], evaluatedAt));
+ const changed = items.filter(item => item.changed);
+
+ writeJson(statePath, Object.fromEntries(items.map(item => [item.id, {
+ state: item.state,
+ score: item.score,
+ confidence: item.confidence,
+ lastTriggerTime: item.lastTriggerTime,
+ updatedAt: evaluatedAt,
+ }])));
+
+ return {
+ available: true,
+ path: loaded.path,
+ items,
+ changed,
+ };
+}
+
+export function loadScenarioDefinitions(runsDir) {
+ const path = join(runsDir, 'scenarios.json');
+ try {
+ if (!existsSync(runsDir)) mkdirSync(runsDir, { recursive: true });
+ if (!existsSync(path)) {
+ writeJson(path, {
+ version: 1,
+ scenarios: DEFAULT_SCENARIOS,
+ });
+ }
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
+ if (!raw || !Array.isArray(raw.scenarios)) throw new Error('scenarios must be an array');
+ const scenarios = raw.scenarios
+ .map(normalizeScenario)
+ .filter(Boolean);
+ return { ok: true, path, scenarios };
+ } catch (err) {
+ return { ok: false, path, error: err.message };
+ }
+}
+
+function normalizeScenario(input) {
+ if (!input || typeof input !== 'object') return null;
+ const id = String(input.id || input.name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const name = String(input.name || input.id || '').trim();
+ if (!id || !name) return null;
+ const thresholds = input.thresholds || {};
+ return {
+ id,
+ enabled: input.enabled === true,
+ name,
+ description: String(input.description || ''),
+ regions: arrayOfStrings(input.regions),
+ categories: arrayOfStrings(input.categories),
+ keywords: arrayOfStrings(input.keywords).map(s => s.toLowerCase()),
+ thresholds: {
+ watching: Number(thresholds.watching || 2),
+ building: Number(thresholds.building || 4),
+ confirmed: Number(thresholds.confirmed || 7),
+ },
+ invalidation: String(input.invalidation || ''),
+ };
+}
+
+function evaluateScenario(def, corpus, previous, evaluatedAt) {
+ if (!def.enabled) {
+ return {
+ ...publicScenario(def),
+ state: 'dormant',
+ score: 0,
+ confidence: 0,
+ evidence: [],
+ changed: previous?.state && previous.state !== 'dormant',
+ lastTriggerTime: previous?.lastTriggerTime || null,
+ };
+ }
+
+ const evidence = [];
+ let score = 0;
+ for (const keyword of def.keywords) {
+ const hit = corpus.entries.find(entry => entry.text.includes(keyword));
+ if (hit) {
+ score += 1;
+ evidence.push({ type: 'keyword', label: keyword, source: hit.source, text: hit.original.slice(0, 180) });
+ }
+ }
+ for (const region of def.regions) {
+ const needle = region.toLowerCase();
+ const hit = corpus.entries.find(entry => entry.text.includes(needle));
+ if (hit) {
+ score += 1;
+ evidence.push({ type: 'region', label: region, source: hit.source, text: hit.original.slice(0, 180) });
+ }
+ }
+ for (const category of def.categories) {
+ if (corpus.categories.has(category.toLowerCase())) {
+ score += 1;
+ evidence.push({ type: 'category', label: category, source: 'sweep', text: `${category} category active` });
+ }
+ }
+
+ const state = score >= def.thresholds.confirmed ? 'confirmed'
+ : score >= def.thresholds.building ? 'building'
+ : score >= def.thresholds.watching ? 'watching'
+ : 'dormant';
+ const confidence = Math.min(100, Math.round((score / Math.max(1, def.thresholds.confirmed)) * 100));
+ const changed = previous?.state ? previous.state !== state : state !== 'dormant';
+ return {
+ ...publicScenario(def),
+ state,
+ score,
+ confidence,
+ evidence: evidence.slice(0, 6),
+ changed,
+ lastTriggerTime: state === 'dormant' ? (previous?.lastTriggerTime || null) : evaluatedAt,
+ };
+}
+
+function publicScenario(def) {
+ return {
+ id: def.id,
+ name: def.name,
+ description: def.description,
+ enabled: def.enabled,
+ invalidation: def.invalidation,
+ };
+}
+
+function buildCorpus(data, delta) {
+ const entries = [];
+ const categories = new Set();
+ const push = (source, text, category) => {
+ if (!text) return;
+ entries.push({ source, original: String(text), text: String(text).toLowerCase() });
+ if (category) categories.add(category);
+ };
+
+ for (const signal of data.tSignals || []) push('thermal', signal, 'thermal');
+ for (const post of data.tg?.urgent || []) push(post.channel || 'telegram', post.text, 'osint');
+ for (const item of data.newsFeed || []) push(item.source || 'news', item.headline || item.title, 'news');
+ for (const item of data.news || []) push(item.source || 'news', item.headline || item.title, 'news');
+ for (const item of data.acled?.deadliestEvents || []) push('ACLED', `${item.country || ''} ${item.location || ''} ${item.event_type || ''} ${item.fatalities || ''}`, 'conflict');
+ for (const item of data.air || []) push('OpenSky', `${item.region} ${item.total} aircraft`, 'air');
+ for (const item of data.chokepoints || []) push('Maritime', `${item.label} ${item.note}`, 'maritime');
+ if (data.energy?.wti || data.energy?.brent) push('energy', `WTI ${data.energy.wti} Brent ${data.energy.brent}`, 'energy');
+ if (data.markets?.vix || data.fred?.some(f => f.id === 'VIXCLS')) push('markets', 'VIX volatility market stress', 'markets');
+ if (delta?.summary) push('delta', `${delta.summary.direction} ${delta.summary.totalChanges} changes ${delta.summary.criticalChanges} critical`, 'delta');
+ for (const signal of delta?.signals?.new || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
+ for (const signal of delta?.signals?.escalated || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
+
+ return { entries, categories };
+}
+
+function arrayOfStrings(value) {
+ return Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : [];
+}
+
+function readJson(path, fallback) {
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
+}
+
+function writeJson(path, value) {
+ writeFileSync(path, JSON.stringify(value, null, 2));
+}
diff --git a/lib/stale-alerts.mjs b/lib/stale-alerts.mjs
new file mode 100644
index 0000000..02835ac
--- /dev/null
+++ b/lib/stale-alerts.mjs
@@ -0,0 +1,52 @@
+const DEFAULT_COOLDOWN_MS = 60 * 60 * 1000;
+
+export function shouldSendStaleAlert(health, state = {}, opts = {}) {
+ const now = opts.now ?? Date.now();
+ const cooldownMs = opts.cooldownMs ?? DEFAULT_COOLDOWN_MS;
+ if (!health?.stale) {
+ state.lastStaleAlertKey = null;
+ return { send: false, reason: 'not_stale' };
+ }
+
+ const key = [
+ health.lastSuccessfulSweep || 'never',
+ health.lastSweepError || 'no-error',
+ health.sourcesFailed || 0,
+ health.sourcesDegraded || 0,
+ ].join('|');
+
+ if (state.lastStaleAlertKey === key && now - (state.lastStaleAlertAt || 0) < cooldownMs) {
+ return { send: false, reason: 'cooldown', key };
+ }
+
+ state.lastStaleAlertKey = key;
+ state.lastStaleAlertAt = now;
+ return { send: true, reason: 'stale', key };
+}
+
+export function formatStaleAlert(health, opts = {}) {
+ const dashboardUrl = opts.dashboardUrl || 'http://localhost:3117';
+ const context = opts.context || 'scheduled sweep';
+ const ageMinutes = health.dataAgeSeconds == null ? 'unknown' : Math.floor(health.dataAgeSeconds / 60);
+ const affected = (health.sourceHealth || [])
+ .filter(s => (s.status && s.status !== 'ok') || s.error)
+ .slice(0, 6)
+ .map(s => `- ${s.name || s.n || 'source'}: ${s.status || 'degraded'}${s.error ? ` (${String(s.error).slice(0, 100)})` : ''}`);
+
+ return [
+ '*CRUCIX STALE DATA ALERT*',
+ '',
+ `Context: ${context}`,
+ `Status: ${health.status || 'unknown'}`,
+ `Data age: ${ageMinutes} minutes`,
+ `Last successful sweep: ${health.lastSuccessfulSweep || 'never'}`,
+ `Last attempted sweep: ${health.lastSweep || 'never'}`,
+ `Last error: ${health.lastSweepError || 'none'}`,
+ `Sources: ${health.sourcesOk || 0} OK / ${health.sourcesDegraded || 0} degraded / ${health.sourcesFailed || 0} failed`,
+ '',
+ '*Affected sources*',
+ affected.length ? affected.join('\n') : '- No per-source errors available',
+ '',
+ `Dashboard: ${dashboardUrl}`,
+ ].join('\n');
+}
diff --git a/package-lock.json b/package-lock.json
index b803cdd..7016c81 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
diff --git a/package.json b/package.json
index 4f3e7cc..d1a48e4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
- "description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
+ "description": "Docker-first local intelligence terminal with 27 OSINT sources, live dashboard, source health, auto-refresh, and optional LLM layer.",
"type": "module",
"scripts": {
"start": "node server.mjs",
@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs",
"test": "npm run test:unit",
- "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs",
+ "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/dashboard-geotagging.test.mjs",
"compose:config": "docker compose config",
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"
@@ -23,7 +23,7 @@
"dashboard",
"geopolitical"
],
- "author": "Crucix",
+ "author": "Intelligence Terminal contributors",
"license": "AGPL-3.0-only",
"engines": {
"node": ">=22",
diff --git a/server.mjs b/server.mjs
index 95949f0..eb661c6 100644
--- a/server.mjs
+++ b/server.mjs
@@ -18,6 +18,8 @@ import { TelegramAlerter } from './lib/alerts/telegram.mjs';
import { DiscordAlerter } from './lib/alerts/discord.mjs';
import { getFetchMetrics } from './apis/utils/fetch.mjs';
import { IntelligenceStore } from './lib/intelligence-store.mjs';
+import { formatStaleAlert, shouldSendStaleAlert } from './lib/stale-alerts.mjs';
+import { evaluateScenarios } from './lib/scenarios.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
@@ -39,6 +41,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
let sweepInProgress = false;
const startTime = Date.now();
const sseClients = new Set();
+const staleAlertState = {};
// === Delta/Memory ===
const memory = new MemoryManager(RUNS_DIR);
@@ -411,6 +414,31 @@ function buildHealth() {
};
}
+async function notifyIfDataStale(context = 'scheduled sweep') {
+ const health = buildHealth();
+ const decision = shouldSendStaleAlert(health, staleAlertState, {
+ cooldownMs: config.staleAlertCooldownMinutes * 60 * 1000,
+ });
+ if (!decision.send) return false;
+
+ const dashboardUrl = config.dashboardUrl || `http://localhost:${config.port}`;
+ const message = formatStaleAlert(health, { dashboardUrl, context });
+ const sends = [];
+ if (telegramAlerter.isConfigured) sends.push(telegramAlerter.sendMessage(message));
+ if (discordAlerter.isConfigured) sends.push(discordAlerter.sendAlert(message));
+
+ if (sends.length === 0) {
+ console.warn('[Crucix] Data is stale but no operator alert channel is configured');
+ return false;
+ }
+
+ const results = await Promise.allSettled(sends);
+ const sent = results.some(r => r.status === 'fulfilled' && (r.value === true || r.value?.ok === true));
+ if (sent) console.warn('[Crucix] Operator stale-data alert sent');
+ else console.warn('[Crucix] Operator stale-data alert attempted but no channel accepted it');
+ return sent;
+}
+
function buildBrief(data) {
const verbosity = config.telegram.briefVerbosity || 'standard';
const delta = memory.getLastDelta();
@@ -447,6 +475,13 @@ function buildBrief(data) {
lines.push('', '*Why This Matters*');
for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`);
}
+ const scenarioChanges = data.scenarios?.changed || [];
+ if (scenarioChanges.length) {
+ lines.push('', '*Scenario Watchlist*');
+ for (const scenario of scenarioChanges.slice(0, 4)) {
+ lines.push(`- ${scenario.name}: ${scenario.state.toUpperCase()} (${scenario.confidence}% confidence)`);
+ }
+ }
lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.');
return lines.join('\n');
}
@@ -493,6 +528,7 @@ async function runSweepCycle() {
// 4. Delta computation + memory
const delta = memory.addRun(synthesized);
synthesized.delta = delta;
+ synthesized.scenarios = evaluateScenarios(synthesized, delta, RUNS_DIR);
// 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep
if (llmProvider?.isConfigured) {
@@ -553,6 +589,9 @@ async function runSweepCycle() {
broadcast({ type: 'sweep_error', error: err.message });
} finally {
sweepInProgress = false;
+ await notifyIfDataStale(lastSweepError ? 'failed sweep' : 'completed sweep').catch(err => {
+ console.error('[Crucix] Stale-data operator alert failed:', err.message);
+ });
}
}
diff --git a/test/acled-source.test.mjs b/test/acled-source.test.mjs
new file mode 100644
index 0000000..a145648
--- /dev/null
+++ b/test/acled-source.test.mjs
@@ -0,0 +1,95 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { authenticate, briefing, resetAcledSessionCache } from '../apis/sources/acled.mjs';
+
+function jsonResponse(status, body, ok = status >= 200 && status < 300) {
+ return {
+ ok,
+ status,
+ headers: { getSetCookie: () => [] },
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+}
+
+test('ACLED reports missing credentials without network access', async () => {
+ resetAcledSessionCache();
+ let calls = 0;
+ const data = await briefing({
+ env: {},
+ fetchImpl: async () => {
+ calls++;
+ throw new Error('unexpected network access');
+ },
+ });
+
+ assert.equal(calls, 0);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'missing_acled_credentials');
+ assert.deepEqual(data.missing, ['ACLED_EMAIL', 'ACLED_PASSWORD']);
+});
+
+test('ACLED accepts ACLED_USER as email alias and returns empty valid result', async () => {
+ resetAcledSessionCache();
+ const urls = [];
+ const data = await briefing({
+ env: { ACLED_USER: 'analyst@example.test', ACLED_PASSWORD: 'secret' },
+ fetchImpl: async url => {
+ urls.push(String(url));
+ if (String(url).includes('/oauth/token')) {
+ return jsonResponse(200, { access_token: 'token' });
+ }
+ return jsonResponse(200, { status: 200, data: [] });
+ },
+ });
+
+ assert.equal(data.status, 'ok');
+ assert.equal(data.totalEvents, 0);
+ assert.ok(urls.some(url => url.includes('/oauth/token')));
+ assert.ok(urls.some(url => url.includes('/api/acled/read')));
+});
+
+test('ACLED classifies auth failure without exposing credentials', async () => {
+ resetAcledSessionCache();
+ const result = await authenticate({
+ env: { ACLED_EMAIL: 'analyst@example.test', ACLED_PASSWORD: 'super-secret' },
+ fetchImpl: async url => {
+ if (String(url).includes('/oauth/token')) {
+ return jsonResponse(401, { error: 'invalid_grant' }, false);
+ }
+ return {
+ ok: false,
+ status: 403,
+ headers: { getSetCookie: () => [] },
+ text: async () => 'forbidden',
+ };
+ },
+ });
+
+ assert.equal(result.status, 'auth_failed');
+ assert.equal(result.error, 'acled_auth_failed');
+ assert.equal(result.diagnostics.length, 2);
+ assert.doesNotMatch(JSON.stringify(result), /super-secret/);
+});
+
+test('ACLED classifies data access denied distinctly from auth failure', async () => {
+ resetAcledSessionCache();
+ const data = await briefing({
+ env: { ACLED_EMAIL: 'analyst@example.test', ACLED_PASSWORD: 'secret' },
+ fetchImpl: async url => {
+ if (String(url).includes('/oauth/token')) {
+ return jsonResponse(200, { access_token: 'token' });
+ }
+ return {
+ ok: false,
+ status: 403,
+ headers: { getSetCookie: () => [] },
+ text: async () => 'terms not accepted',
+ };
+ },
+ });
+
+ assert.equal(data.status, 'access_denied');
+ assert.equal(data.error, 'acled_data_http_403');
+ assert.match(data.hint, /Accept ACLED terms/);
+});
diff --git a/test/dashboard-geotagging.test.mjs b/test/dashboard-geotagging.test.mjs
new file mode 100644
index 0000000..cab73dc
--- /dev/null
+++ b/test/dashboard-geotagging.test.mjs
@@ -0,0 +1,47 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { geoTagText, stableGeoJitter } from '../dashboard/inject.mjs';
+
+test('geoTagText matches headlines case-insensitively', () => {
+ assert.deepEqual(geoTagText('ukraine reports new air defense activity'), {
+ lat: 49,
+ lon: 32,
+ region: 'Ukraine',
+ });
+
+ assert.deepEqual(geoTagText('flooding disrupts são paulo transport'), {
+ lat: -23.5,
+ lon: -46.6,
+ region: 'São Paulo',
+ });
+});
+
+test('geoTagText prefers longer place names before broad countries', () => {
+ assert.deepEqual(geoTagText('New York markets react before wider US session'), {
+ lat: 40.7,
+ lon: -74,
+ region: 'New York',
+ });
+});
+
+test('geoTagText uses word boundaries to reduce false positives', () => {
+ assert.equal(geoTagText('A music festival announces its lineup'), null);
+ assert.equal(geoTagText('Officials discuss a new focus for aid'), null);
+ assert.deepEqual(geoTagText('US officials discuss a new aid package'), {
+ lat: 39,
+ lon: -98,
+ region: 'US',
+ });
+});
+
+test('stableGeoJitter is deterministic and bounded', () => {
+ const key = 'BBC|lower-case ukraine headline|Sun, 17 May 2026 12:00:00 GMT|https://example.test/a';
+ const latA = stableGeoJitter(key, 'lat');
+ const latB = stableGeoJitter(key, 'lat');
+ const lon = stableGeoJitter(key, 'lon');
+
+ assert.equal(latA, latB);
+ assert.notEqual(latA, lon);
+ assert.ok(latA >= -1 && latA <= 1);
+ assert.ok(lon >= -1 && lon <= 1);
+});
diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs
index 779e731..e740690 100644
--- a/test/fetch-utils.test.mjs
+++ b/test/fetch-utils.test.mjs
@@ -2,9 +2,11 @@ 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';
+import { formatStaleAlert, shouldSendStaleAlert } from '../lib/stale-alerts.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
const originalFetch = globalThis.fetch;
+ const source = 'unit-html-once';
globalThis.fetch = async () => ({
ok: true,
status: 200,
@@ -12,9 +14,72 @@ test('safeFetch reports HTML as degraded JSON response', async () => {
text: async () => 'not json',
});
try {
- const data = await safeFetch('https://example.test/json', { retries: 0, source: 'unit' });
+ const data = await safeFetch('https://example.test/json', { retries: 0, source });
assert.match(data.error, /Expected JSON/);
- assert.ok(getFetchMetrics().bySource.unit.requests >= 1);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 1);
+ assert.equal(bucket.ok, 0);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 200);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('safeFetch records HTTP failure once with status and bytes', async () => {
+ const originalFetch = globalThis.fetch;
+ const source = 'unit-http-failure-once';
+ globalThis.fetch = async () => ({
+ ok: false,
+ status: 503,
+ headers: { get: () => 'application/json' },
+ text: async () => 'service unavailable',
+ });
+ try {
+ const data = await safeFetch('https://example.test/fail', { retries: 0, source });
+ assert.match(data.error, /HTTP 503/);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 1);
+ assert.equal(bucket.ok, 0);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 503);
+ assert.equal(bucket.bytes, 'service unavailable'.length);
+ assert.match(bucket.lastError, /HTTP 503/);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('safeFetch retry metrics count one record per attempt', async () => {
+ const originalFetch = globalThis.fetch;
+ const source = 'unit-retry-attempts';
+ let calls = 0;
+ globalThis.fetch = async () => {
+ calls += 1;
+ if (calls === 1) {
+ return {
+ ok: false,
+ status: 502,
+ headers: { get: () => 'application/json' },
+ text: async () => 'bad gateway',
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ text: async () => '{"ok":true}',
+ };
+ };
+ try {
+ const data = await safeFetch('https://example.test/retry', { retries: 1, source });
+ assert.equal(data.ok, true);
+ assert.equal(calls, 2);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 2);
+ assert.equal(bucket.ok, 1);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 200);
} finally {
globalThis.fetch = originalFetch;
}
@@ -53,3 +118,75 @@ test('server dashboard fetches api data before initialization', () => {
assert.ok(apiFetch > serverMode);
assert.ok(firstInitAfterServerMode > apiFetch);
});
+
+test('stale alert is skipped for fresh health and resets active key', () => {
+ const state = { lastStaleAlertKey: 'old', lastStaleAlertAt: 100 };
+ const decision = shouldSendStaleAlert({ stale: false }, state, { now: 200 });
+ assert.equal(decision.send, false);
+ assert.equal(decision.reason, 'not_stale');
+ assert.equal(state.lastStaleAlertKey, null);
+});
+
+test('stale alert sends once and deduplicates during cooldown', () => {
+ const state = {};
+ const health = {
+ stale: true,
+ lastSuccessfulSweep: '2026-05-17T08:00:00.000Z',
+ lastSweepError: 'network timeout',
+ sourcesFailed: 2,
+ sourcesDegraded: 1,
+ };
+
+ const first = shouldSendStaleAlert(health, state, { now: 1_000, cooldownMs: 60_000 });
+ const second = shouldSendStaleAlert(health, state, { now: 2_000, cooldownMs: 60_000 });
+
+ assert.equal(first.send, true);
+ assert.equal(second.send, false);
+ assert.equal(second.reason, 'cooldown');
+});
+
+test('stale alert repeats after cooldown', () => {
+ const state = {};
+ const health = { stale: true, lastSuccessfulSweep: 'a', lastSweepError: 'timeout', sourcesFailed: 1 };
+
+ assert.equal(shouldSendStaleAlert(health, state, { now: 1_000, cooldownMs: 60_000 }).send, true);
+ assert.equal(shouldSendStaleAlert(health, state, { now: 62_000, cooldownMs: 60_000 }).send, true);
+});
+
+test('stale alert message includes operator context and affected sources', () => {
+ const message = formatStaleAlert({
+ status: 'stale',
+ stale: true,
+ dataAgeSeconds: 7200,
+ lastSuccessfulSweep: '2026-05-17T08:00:00.000Z',
+ lastSweep: '2026-05-17T10:00:00.000Z',
+ lastSweepError: 'GDELT timeout',
+ sourcesOk: 20,
+ sourcesDegraded: 3,
+ sourcesFailed: 2,
+ sourceHealth: [
+ { name: 'GDELT', status: 'degraded', error: 'timeout' },
+ { name: 'Reddit', status: 'no_credentials' },
+ ],
+ }, { dashboardUrl: 'https://terminal.example.test', context: 'failed sweep' });
+
+ assert.match(message, /CRUCIX STALE DATA ALERT/);
+ assert.match(message, /Data age: 120 minutes/);
+ assert.match(message, /GDELT: degraded \(timeout\)/);
+ assert.match(message, /Dashboard: https:\/\/terminal\.example\.test/);
+});
+
+test('scenario watchlist feature is wired into sweep, briefing, and dashboard', () => {
+ const scenarios = readFileSync(new URL('../lib/scenarios.mjs', import.meta.url), 'utf8');
+ const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
+ const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
+ const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
+ assert.match(scenarios, /DEFAULT_SCENARIOS/);
+ assert.match(scenarios, /runsDir, 'scenarios\.json'/);
+ assert.match(scenarios, /scenario-state\.json/);
+ assert.match(scenarios, /watching.*building.*confirmed/s);
+ assert.match(server, /evaluateScenarios\(synthesized, delta, RUNS_DIR\)/);
+ assert.match(server, /\*Scenario Watchlist\*/);
+ assert.match(html, /Scenario Watchlist/);
+ assert.match(readme, /runs\/scenarios\.json/);
+});
diff --git a/test/mojibake-text.test.mjs b/test/mojibake-text.test.mjs
new file mode 100644
index 0000000..e624fa5
--- /dev/null
+++ b/test/mojibake-text.test.mjs
@@ -0,0 +1,65 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const TEXT_ROOTS = ['locales'];
+
+const TEXT_FILES = [];
+
+const EXTENSIONS = new Set(['.json', '.html', '.mjs']);
+
+const MOJIBAKE_PATTERNS = [
+ { name: 'latin1-accent', pattern: /\u00c3./g },
+ { name: 'stray-cp1252-prefix', pattern: /\u00c2./g },
+ { name: 'emoji-mojibake', pattern: /\u00f0\u0178/g },
+ {
+ name: 'punctuation-mojibake',
+ pattern: /\u00e2[\u0080-\u009f\u20ac\u0153\u2018\u2019\u201c\u201d\u2013\u2014\u2022\u2026\u201e\u2021\u02c6\u2030\u2039\u203a\u0152\u017d]/g,
+ },
+ { name: 'variation-selector-mojibake', pattern: /\u00ef\u00b8/g },
+ { name: 'ligature-mojibake', pattern: /\u00c5[\u0080-\u017f]/g },
+ { name: 'replacement-character', pattern: /\ufffd/g },
+];
+
+function collectFiles(root) {
+ const out = [];
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
+ const path = join(root, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...collectFiles(path));
+ } else if (EXTENSIONS.has(path.slice(path.lastIndexOf('.')))) {
+ out.push(path);
+ }
+ }
+ return out;
+}
+
+function textFiles() {
+ const discovered = TEXT_ROOTS.flatMap(root => collectFiles(root));
+ const explicit = TEXT_FILES.filter(path => statSync(path, { throwIfNoEntry: false })?.isFile());
+ return [...new Set([...discovered, ...explicit])].sort();
+}
+
+test('locale JSON files are valid UTF-8 JSON', () => {
+ for (const file of collectFiles('locales')) {
+ assert.doesNotThrow(() => JSON.parse(readFileSync(file, 'utf8')), `${file} must parse as JSON`);
+ }
+});
+
+test('locale text does not contain known mojibake sequences', () => {
+ const failures = [];
+
+ for (const file of textFiles()) {
+ const text = readFileSync(file, 'utf8');
+ for (const { name, pattern } of MOJIBAKE_PATTERNS) {
+ for (const match of text.matchAll(pattern)) {
+ const start = Math.max(0, match.index - 30);
+ const end = Math.min(text.length, match.index + 50);
+ failures.push(`${file}: ${name}: ${JSON.stringify(text.slice(start, end))}`);
+ }
+ }
+ }
+
+ assert.deepEqual(failures, []);
+});
diff --git a/test/reddit-source.test.mjs b/test/reddit-source.test.mjs
new file mode 100644
index 0000000..1e61620
--- /dev/null
+++ b/test/reddit-source.test.mjs
@@ -0,0 +1,109 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { briefing, getHot, getRedditConfig, getToken } from '../apis/sources/reddit.mjs';
+
+test('Reddit reports missing OAuth credentials without network access', async () => {
+ let calls = 0;
+ const data = await briefing({
+ env: {},
+ delayMs: 0,
+ fetchImpl: async () => {
+ calls++;
+ throw new Error('unexpected network access');
+ },
+ });
+
+ assert.equal(calls, 0);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'missing_reddit_oauth_credentials');
+ assert.deepEqual(data.missing, ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET']);
+});
+
+test('Reddit hot posts require OAuth token and never use public JSON fallback', async () => {
+ const originalFetch = globalThis.fetch;
+ let calledUrl = null;
+ globalThis.fetch = async url => {
+ calledUrl = url;
+ throw new Error('unexpected public fallback');
+ };
+
+ try {
+ const data = await getHot('worldnews');
+ assert.equal(calledUrl, null);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'reddit_oauth_required');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit classifies OAuth HTTP failure without exposing secrets', async () => {
+ const result = await getToken({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ fetchImpl: async () => ({
+ ok: false,
+ status: 401,
+ text: async () => 'invalid client',
+ }),
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.status, 'auth_failed');
+ assert.equal(result.error, 'reddit_oauth_http_401');
+ assert.doesNotMatch(JSON.stringify(result), /client-secret/);
+});
+
+test('Reddit fetches hot posts through oauth.reddit.com when configured', async () => {
+ const originalFetch = globalThis.fetch;
+ const urls = [];
+ globalThis.fetch = async url => {
+ urls.push(String(url));
+ if (String(url).includes('/api/v1/access_token')) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ access_token: 'test-token' }),
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ text: async () => JSON.stringify({
+ data: {
+ children: [
+ {
+ data: {
+ title: 'Market stress headline',
+ score: 42,
+ num_comments: 7,
+ url: 'https://example.test/post',
+ created_utc: 1700000000,
+ },
+ },
+ ],
+ },
+ }),
+ };
+ };
+
+ try {
+ const data = await briefing({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ subreddits: ['worldnews'],
+ delayMs: 0,
+ });
+
+ assert.equal(data.status, 'ok');
+ assert.equal(data.subreddits.worldnews[0].title, 'Market stress headline');
+ assert.ok(urls.some(url => url === 'https://www.reddit.com/api/v1/access_token'));
+ assert.ok(urls.some(url => url.startsWith('https://oauth.reddit.com/r/worldnews/hot')));
+ assert.equal(urls.some(url => url.includes('hot.json')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit config reports partial credential state', () => {
+ assert.deepEqual(getRedditConfig({ REDDIT_CLIENT_ID: 'id' }).missing, ['REDDIT_CLIENT_SECRET']);
+});