14 Commits

Author SHA1 Message Date
MrSphay
331175ae3c Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-4-memory-prediction-loop
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	server.mjs
#	test/fetch-utils.test.mjs
2026-05-17 20:46:02 +02:00
e288881c41 Merge pull request 'fix: harden terminal action endpoints' (#25) from codex/issue-6-terminal-actions-hardening into codex/production-intelligence-terminal
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 14s
Build / test-and-image (push) Successful in 27s
Codex Template Compliance / template-compliance (push) Successful in 4s
Reviewed-on: #25
2026-05-17 18:43:21 +00:00
MrSphay
3069114ffd Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-4-memory-prediction-loop
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 1m3s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:39:38 +02:00
MrSphay
09df127e06 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-6-terminal-actions-hardening
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m4s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:39:04 +02:00
49176b42fd Merge pull request 'fix: remove embedded dashboard snapshot' (#20) from codex/issue-5-dashboard-shell into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 25s
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 6s
Reviewed-on: #20
2026-05-17 18:35:58 +00:00
MrSphay
090e90ea70 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-4-memory-prediction-loop
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 55s
# Conflicts:
#	README.md
#	test/fetch-utils.test.mjs
2026-05-17 20:35:44 +02:00
e70801ae98 Merge branch 'codex/production-intelligence-terminal' into codex/issue-5-dashboard-shell
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 54s
2026-05-17 18:34:34 +00:00
703670e7a0 Merge pull request 'fix: report adsb unavailable state as degraded' (#11) from codex/issue-7-adsb-degraded into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 28s
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
Reviewed-on: #11
2026-05-17 18:33:51 +00:00
MrSphay
1423dca199 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-6-terminal-actions-hardening
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 56s
# Conflicts:
#	README.md
#	server.mjs
#	test/fetch-utils.test.mjs
2026-05-17 20:33:45 +02:00
MrSphay
5113e341b2 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-7-adsb-degraded
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m14s
# Conflicts:
#	package.json
2026-05-17 20:30:53 +02:00
d7f10bf545 merge: update adsb degraded branch
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 1m13s
2026-05-17 19:03:49 +02:00
MrSphay
267af03b22 feat: extend memory prediction loop
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:30:39 +02:00
MrSphay
d7df2e4aee fix: harden terminal action endpoints
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 49s
2026-05-17 14:19:28 +02:00
MrSphay
b2f604b120 fix: report adsb unavailable state as degraded
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m13s
2026-05-17 13:55:42 +02:00
13 changed files with 726 additions and 56 deletions

View File

@@ -10,6 +10,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=
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

@@ -134,6 +134,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=https://intelligence.example.internal
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
LLM_PROVIDER=openrouter
@@ -185,9 +187,52 @@ 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.
When data remains stale past `STALE_DATA_MAX_AGE_MINUTES`, the server sends an operator alert through configured Telegram/Discord channels after failed or degraded sweep attempts. `STALE_ALERT_COOLDOWN_MINUTES` prevents repeated stale alerts from spamming every refresh interval. Set `DASHBOARD_URL` to the Pangolin/public URL you want included in those alerts.
The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
#### Memory And Prediction Loop
Crucix stores longitudinal memory in `runs/intelligence.db` when the current Node.js build exposes `node:sqlite`. If SQLite is unavailable, the file is created as a harmless placeholder and `/api/health` reports the memory store as unavailable instead of failing the sweep.
The memory layer persists:
| Table | Purpose |
| --- | --- |
| `runs` | Sweep timestamps, source health counts, and delta direction summaries. |
| `entities` | Stable entity IDs for recurring countries, regions, and locations. |
| `events` | Stable event IDs for conflict, OSINT, urgent news, and new delta signals across sweeps. |
| `predictions` | Trade/intelligence hypotheses with evidence, confidence, horizon, outcome state, and latest grading. |
Query endpoints:
```text
GET /api/memory/search?q=iran&limit=25
GET /api/memory/predictions?state=open&limit=25
```
Memory endpoints use the same operator authorization gate as Terminal Actions. The dashboard Terminal Actions panel includes a `Memory` action for a quick operator-facing view of recent events and prediction states.
Retention, backup, and privacy expectations:
- Treat `runs/intelligence.db` as operator data. It can contain source excerpts, headlines, generated hypotheses, and URLs from your configured feeds.
- Back up `runs/` with the rest of your Dockge volume if you want longitudinal learning to survive container replacement.
- Delete `runs/intelligence.db` to reset SQLite memory; the next sweep recreates the schema.
- Do not commit `runs/` or `.env`. API credentials stay in `.env`; memory stores derived observations, not secrets.
- If you expose the dashboard through a reverse proxy, protect Terminal Actions and memory queries behind your normal authentication boundary.
#### Scenario Watchlist

View File

@@ -59,7 +59,8 @@ export async function runSource(name, fn, ...args) {
});
const data = await Promise.race([dataPromise, timeoutPromise]);
const hasError = Boolean(data?.error);
const isDegraded = hasError || ['no_credentials', 'degraded', 'failed'].includes(data?.status);
const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
const isDegraded = hasError || degradedStatuses.includes(data?.status);
return {
name,
status: isDegraded ? 'degraded' : 'ok',

View File

@@ -1,7 +1,8 @@
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
// Public feed access varies; RapidAPI tier available for programmatic use.
// This module attempts the public endpoints and falls back to a documented stub.
// This module reports explicit disabled/degraded state instead of making
// unavailable aircraft data look live.
import { safeFetch } from '../utils/fetch.mjs';
@@ -140,6 +141,7 @@ async function fetchViaRapidApi(apiKey) {
// Get all military aircraft
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
timeout: 20000,
source: 'adsb-rapidapi',
headers: {
'X-RapidAPI-Key': apiKey,
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
@@ -151,21 +153,26 @@ async function fetchViaRapidApi(apiKey) {
// Attempt to fetch from public feed
async function fetchPublicFeed() {
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000 });
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000, source: 'adsb-public' });
return data;
}
// Get military aircraft from available sources
export async function getMilitaryAircraft(apiKey) {
async function getMilitaryAircraftResult(apiKey) {
const failures = [];
// Try RapidAPI first if key available
if (apiKey) {
const data = await fetchViaRapidApi(apiKey);
if (data && !data.error) {
const aircraft = data.ac || data.aircraft || [];
if (Array.isArray(aircraft)) {
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
return {
provider: 'rapidapi',
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
};
}
}
failures.push({ provider: 'rapidapi', error: data?.error || 'RapidAPI returned an unsupported payload' });
}
// Try public feed
@@ -173,11 +180,21 @@ export async function getMilitaryAircraft(apiKey) {
if (pubData && !pubData.error) {
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
if (Array.isArray(aircraft)) {
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
return {
provider: 'public-feed',
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
};
}
}
failures.push({ provider: 'public-feed', error: pubData?.error || 'Public feed returned an unsupported payload' });
return null; // all sources failed
return { provider: null, aircraft: null, failures };
}
// Get military aircraft from available sources
export async function getMilitaryAircraft(apiKey) {
const result = await getMilitaryAircraftResult(apiKey);
return result.aircraft;
}
// Get all aircraft in a geographic bounding box via RapidAPI
@@ -208,7 +225,8 @@ export async function getAircraftInArea(lat, lon, radiusNm = 250, apiKey) {
// Briefing — attempt to get military flight data, document what's available
export async function briefing() {
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
const militaryAircraft = await getMilitaryAircraft(apiKey);
const result = await getMilitaryAircraftResult(apiKey);
const militaryAircraft = result.aircraft;
// If we got data, analyze it
if (militaryAircraft && militaryAircraft.length > 0) {
@@ -255,6 +273,7 @@ export async function briefing() {
source: 'ADS-B Exchange',
timestamp: new Date().toISOString(),
status: 'live',
provider: result.provider,
totalMilitary: militaryAircraft.length,
byCountry,
categories: {
@@ -269,10 +288,18 @@ export async function briefing() {
}
// No data available — return stub with integration documentation
const status = apiKey ? 'degraded' : 'disabled';
const error = apiKey
? 'ADS-B providers returned no usable aircraft data'
: 'ADSB_API_KEY or RAPIDAPI_KEY is not configured';
return {
source: 'ADS-B Exchange',
timestamp: new Date().toISOString(),
status: apiKey ? 'error' : 'no_key',
status,
provider: result.provider,
error,
failures: result.failures,
militaryAircraft: [],
message: apiKey
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'

View File

@@ -26,7 +26,9 @@ export default {
staleAlertCooldownMinutes: intEnv('STALE_ALERT_COOLDOWN_MINUTES', 60),
dashboardUrl: process.env.DASHBOARD_URL || null,
sweepToken: process.env.SWEEP_TOKEN || null,
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true),
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,7 +83,7 @@ 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-grid{display:grid;grid-template-columns:repeat(4,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}
@@ -432,6 +432,7 @@ 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'],
@@ -632,6 +633,7 @@ 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>
@@ -644,12 +646,26 @@ 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'; }
@@ -1592,6 +1608,12 @@ function renderLower(){
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();
@@ -1600,7 +1622,7 @@ async function runTerminalAction(action){
method:'POST',
headers:{
'Content-Type':'application/json',
...(localStorage.getItem('crucix_sweep_token') ? {'x-crucix-token': localStorage.getItem('crucix_sweep_token')} : {})
...(token ? {'x-crucix-token': token} : {})
},
body:JSON.stringify({action})
});
@@ -1619,6 +1641,17 @@ async function runTerminalAction(action){
].join('\n');
}else if(action === 'brief'){
terminalOutput = `> brief\n${payload.text || 'No briefing text returned.'}`;
}else if(action === 'memory'){
const events = payload.recentEvents || [];
const predictions = payload.predictions || [];
terminalOutput = [
'> memory',
`Store: ${payload.memory?.available ? 'available' : 'unavailable'}`,
`Recent events: ${events.length}`,
...events.slice(0,4).map(e => `- ${e.kind}: ${e.name}${e.region ? ' [' + e.region + ']' : ''}`),
`Predictions: ${predictions.length}`,
...predictions.slice(0,4).map(p => `- ${p.outcome_state || 'open'}: ${p.title}`)
].join('\n');
}else if(action === 'sweep'){
terminalOutput = `> sweep\n${payload.status === 'already_running' ? 'Sweep already running.' : 'Sweep accepted. The dashboard will update when the sweep finishes.'}`;
}
@@ -1685,7 +1718,9 @@ function renderRight(){
<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>
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('memory')">Memory</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">

View File

@@ -16,4 +16,5 @@ Source docs:
- [Telegram](telegram.md)
- [FIRMS](firms.md)
- [Maritime](maritime.md)
- [ADS-B](adsb.md)
- [Reddit](reddit.md)

24
docs/sources/adsb.md Normal file
View File

@@ -0,0 +1,24 @@
# ADS-B Source
ADS-B Exchange support is optional and intended for unfiltered aircraft and military-flight awareness.
- Source module: `apis/sources/adsb.mjs`
- Preferred provider: ADS-B Exchange via RapidAPI
- Credentials: `ADSB_API_KEY` or `RAPIDAPI_KEY`
- Runtime status without credentials: `disabled`
- Runtime status when providers fail: `degraded`
- Runtime status with usable aircraft payloads: `live`
The source does not treat a missing key or unavailable public feed as normal live data. `/api/health` and `/api/metrics` surface the degraded source state through the sweep source summary.
Known failure modes:
- Missing `ADSB_API_KEY` / `RAPIDAPI_KEY`: source is disabled with operator guidance.
- RapidAPI rejects or rate-limits the request: source is degraded and records provider failure detail.
- Public feed is blocked, rate-limited, or changes shape: source remains degraded instead of returning stale-looking data.
Register for the provider documented in the README, then set:
```env
ADSB_API_KEY=<rapidapi-key>
```

View File

@@ -2,6 +2,9 @@
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { createHash } from 'crypto';
const PREDICTION_STATES = new Set(['open', 'monitoring', 'observed', 'expired_unverified', 'invalidated']);
export class IntelligenceStore {
constructor(dbPath) {
@@ -30,15 +33,24 @@ export class IntelligenceStore {
);
CREATE TABLE IF NOT EXISTS predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stable_id TEXT UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT,
title TEXT NOT NULL,
type TEXT,
hypothesis TEXT,
evidence_json TEXT,
confidence TEXT,
horizon TEXT,
outcome_state TEXT DEFAULT 'open',
outcome_json TEXT,
last_evaluated_at TEXT,
source TEXT,
payload_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stable_id TEXT UNIQUE,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
name TEXT NOT NULL,
@@ -46,7 +58,21 @@ export class IntelligenceStore {
count INTEGER DEFAULT 1,
UNIQUE(name, kind)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stable_id TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
region TEXT,
severity TEXT,
source TEXT,
evidence_json TEXT NOT NULL,
count INTEGER DEFAULT 1
);
`);
this._migrate();
this.available = true;
} catch (err) {
this.available = false;
@@ -71,24 +97,141 @@ export class IntelligenceStore {
delta?.summary?.direction || null,
JSON.stringify({ meta, delta: delta?.summary || null }),
);
for (const idea of data.ideas || []) {
this.db.prepare(`INSERT INTO predictions (created_at, title, type, confidence, source, payload_json)
VALUES (?, ?, ?, ?, ?, ?)`).run(
timestamp,
idea.title || 'Untitled idea',
idea.type || null,
idea.confidence || null,
idea.source || data.ideasSource || null,
JSON.stringify(idea),
);
}
this._recordEntities(data, timestamp);
this._recordEvents(data, delta, timestamp);
this.evaluatePredictions(data, timestamp);
this._recordPredictions(data, timestamp);
}
status() {
return { available: this.available, path: this.dbPath, reason: this.reason };
}
queryMemory({ q = '', limit = 25 } = {}) {
if (!this.available || !this.db) return { available: false, reason: this.reason, results: [] };
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
const term = String(q || '').trim();
const like = `%${term}%`;
const where = term
? 'WHERE name LIKE ? OR region LIKE ? OR source LIKE ? OR kind LIKE ?'
: '';
const params = term ? [like, like, like, like, safeLimit] : [safeLimit];
const events = this.db.prepare(`
SELECT stable_id, first_seen, last_seen, kind, name, region, severity, source, count, evidence_json
FROM events
${where}
ORDER BY last_seen DESC
LIMIT ?
`).all(...params).map(row => ({ ...row, evidence: parseJson(row.evidence_json, {}) }));
return { available: true, q: term, results: events };
}
listPredictions({ state = null, limit = 25 } = {}) {
if (!this.available || !this.db) return { available: false, reason: this.reason, predictions: [] };
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
const normalizedState = state && PREDICTION_STATES.has(String(state)) ? String(state) : null;
const rows = normalizedState
? this.db.prepare(`SELECT * FROM predictions WHERE outcome_state = ? ORDER BY created_at DESC LIMIT ?`).all(normalizedState, safeLimit)
: this.db.prepare(`SELECT * FROM predictions ORDER BY created_at DESC LIMIT ?`).all(safeLimit);
return {
available: true,
predictions: rows.map(row => ({
stable_id: row.stable_id,
created_at: row.created_at,
updated_at: row.updated_at,
title: row.title,
type: row.type,
hypothesis: row.hypothesis,
confidence: row.confidence,
horizon: row.horizon,
outcome_state: row.outcome_state,
last_evaluated_at: row.last_evaluated_at,
source: row.source,
evidence: parseJson(row.evidence_json, []),
outcome: parseJson(row.outcome_json, null),
})),
};
}
evaluatePredictions(data, timestamp = new Date().toISOString()) {
if (!this.available || !this.db) return;
const rows = this.db.prepare(`
SELECT id, created_at, title, type, horizon, outcome_state, payload_json
FROM predictions
WHERE outcome_state IN ('open', 'monitoring')
ORDER BY created_at ASC
LIMIT 200
`).all();
for (const row of rows) {
const payload = parseJson(row.payload_json, {});
const evaluation = evaluatePredictionAgainstSweep(row, payload, data, timestamp);
this.db.prepare(`UPDATE predictions
SET outcome_state = ?, outcome_json = ?, last_evaluated_at = ?, updated_at = ?
WHERE id = ?`).run(
evaluation.state,
JSON.stringify(evaluation),
timestamp,
timestamp,
row.id,
);
}
}
_migrate() {
const columns = {
predictions: [
['stable_id', 'TEXT'],
['updated_at', 'TEXT'],
['hypothesis', 'TEXT'],
['evidence_json', 'TEXT'],
['horizon', 'TEXT'],
['outcome_state', "TEXT DEFAULT 'open'"],
['outcome_json', 'TEXT'],
['last_evaluated_at', 'TEXT'],
],
entities: [
['stable_id', 'TEXT'],
],
};
for (const [table, defs] of Object.entries(columns)) {
for (const [name, type] of defs) {
try { this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`); } catch { }
}
}
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_predictions_stable_id ON predictions(stable_id)`); } catch { }
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_stable_id ON entities(stable_id)`); } catch { }
}
_recordPredictions(data, timestamp) {
for (const idea of data.ideas || []) {
const title = idea.title || 'Untitled idea';
const stableId = stableId('prediction', title, idea.type || '', idea.ticker || '', idea.horizon || '');
const evidence = Array.isArray(idea.signals) ? idea.signals : [];
this.db.prepare(`INSERT INTO predictions (
stable_id, created_at, updated_at, title, type, hypothesis, evidence_json, confidence,
horizon, outcome_state, source, payload_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
ON CONFLICT(stable_id) DO UPDATE SET
updated_at=excluded.updated_at,
confidence=excluded.confidence,
evidence_json=excluded.evidence_json,
payload_json=excluded.payload_json`).run(
stableId,
timestamp,
timestamp,
title,
idea.type || null,
idea.rationale || idea.text || title,
JSON.stringify(evidence),
idea.confidence || null,
idea.horizon || null,
idea.source || data.ideasSource || null,
JSON.stringify(idea),
);
}
}
_recordEntities(data, timestamp) {
const names = [];
for (const item of data.acled?.deadliestEvents || []) {
@@ -99,14 +242,154 @@ export class IntelligenceStore {
if (item.region) names.push([item.region, 'region']);
}
for (const [name, kind] of names.slice(0, 200)) {
this.db.prepare(`INSERT INTO entities (first_seen, last_seen, name, kind, count)
VALUES (?, ?, ?, ?, 1)
const cleanName = String(name).slice(0, 160);
this.db.prepare(`INSERT INTO entities (stable_id, first_seen, last_seen, name, kind, count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(name, kind) DO UPDATE SET last_seen=excluded.last_seen, count=count+1`).run(
stableId('entity', kind, cleanName),
timestamp,
timestamp,
String(name).slice(0, 160),
cleanName,
kind,
);
}
}
_recordEvents(data, delta, timestamp) {
const events = extractEvents(data, delta);
for (const event of events.slice(0, 300)) {
this.db.prepare(`INSERT INTO events (
stable_id, first_seen, last_seen, kind, name, region, severity, source, evidence_json, count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(stable_id) DO UPDATE SET
last_seen=excluded.last_seen,
severity=COALESCE(excluded.severity, severity),
evidence_json=excluded.evidence_json,
count=count+1`).run(
event.stable_id,
timestamp,
timestamp,
event.kind,
event.name,
event.region || null,
event.severity || null,
event.source || null,
JSON.stringify(event.evidence || {}),
);
}
}
}
function stableId(...parts) {
const input = parts.map(part => String(part || '').trim().toLowerCase()).join('|');
return createHash('sha256').update(input).digest('hex').slice(0, 24);
}
function parseJson(value, fallback) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function extractEvents(data, delta) {
const events = [];
const push = ({ kind, name, region, severity, source, evidence }) => {
if (!kind || !name) return;
events.push({
stable_id: stableId('event', kind, name, region || source || ''),
kind,
name: String(name).slice(0, 240),
region: region ? String(region).slice(0, 120) : null,
severity: severity || null,
source: source || null,
evidence: evidence || {},
});
};
for (const item of data.acled?.deadliestEvents || []) {
push({
kind: 'conflict',
name: item.event_type || item.sub_event_type || item.location || item.country,
region: item.country || item.location,
severity: Number(item.fatalities || 0) > 0 ? 'high' : 'medium',
source: 'ACLED',
evidence: item,
});
}
for (const item of data.tg?.urgent || []) {
push({
kind: 'osint',
name: (item.text || '').slice(0, 120),
region: item.region || 'OSINT',
severity: 'high',
source: item.channel || item.chat || 'telegram',
evidence: item,
});
}
for (const item of data.newsFeed || data.news || []) {
if (!item.urgent) continue;
push({
kind: 'news',
name: item.headline || item.title,
region: item.region,
severity: 'medium',
source: item.source,
evidence: item,
});
}
for (const signal of delta?.signals?.new || []) {
push({
kind: 'delta',
name: signal.label || signal.reason || signal.key,
region: signal.region,
severity: signal.severity || 'medium',
source: 'delta',
evidence: signal,
});
}
return events;
}
function evaluatePredictionAgainstSweep(row, payload, data, timestamp) {
const terms = [
row.title,
payload.ticker,
...(Array.isArray(payload.signals) ? payload.signals : []),
].filter(Boolean).map(v => String(v).toLowerCase());
const evidenceText = [
...(data.tSignals || []),
...(data.newsFeed || []).slice(0, 40).map(n => `${n.source || ''} ${n.headline || n.title || ''}`),
...(data.tg?.urgent || []).slice(0, 20).map(p => p.text || ''),
].join('\n').toLowerCase();
const matched = terms.filter(term => term.length >= 4 && evidenceText.includes(term.slice(0, 60)));
const expired = predictionExpired(row.created_at, row.horizon, timestamp);
const state = matched.length
? 'observed'
: expired
? 'expired_unverified'
: 'monitoring';
return {
state,
evaluated_at: timestamp,
matched_terms: matched.slice(0, 10),
expired,
reason: matched.length
? 'Current sweep contains matching evidence terms.'
: expired
? 'Prediction horizon elapsed without matching evidence.'
: 'Prediction remains open for future sweeps.',
};
}
function predictionExpired(createdAt, horizon, nowIso) {
const created = new Date(createdAt).getTime();
const now = new Date(nowIso).getTime();
if (!Number.isFinite(created) || !Number.isFinite(now)) return false;
const text = String(horizon || '').toLowerCase();
const days = text.includes('intraday') ? 1
: text.includes('day') ? 7
: text.includes('week') ? 45
: text.includes('month') ? 180
: text.includes('strategic') ? 365
: 30;
return now - created > days * 24 * 60 * 60 * 1000;
}

View File

@@ -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/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/dashboard-geotagging.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/adsb.test.mjs test/dashboard-geotagging.test.mjs",
"compose:config": "docker compose config",
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"

View File

@@ -41,6 +41,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
let sweepInProgress = false;
const startTime = Date.now();
const sseClients = new Set();
const terminalActionBuckets = new Map();
const staleAlertState = {};
// === Delta/Memory ===
@@ -291,29 +292,67 @@ app.get('/api/metrics', (req, res) => {
});
});
app.post('/api/sweep', express.json(), (req, res) => {
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
triggerSweep(res);
app.get('/api/memory/search', (req, res) => {
const guard = authorizeTerminalAction(req, res, 'memory:search');
if (!guard.ok) return;
auditTerminalAction(req, 'memory:search', 'ok');
res.json(intelligenceStore.queryMemory({
q: req.query.q || '',
limit: req.query.limit || 25,
}));
});
app.post('/api/action', express.json(), async (req, res) => {
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
const action = String(req.body?.action || req.query.action || '').toLowerCase();
app.get('/api/memory/predictions', (req, res) => {
const guard = authorizeTerminalAction(req, res, 'memory:predictions');
if (!guard.ok) return;
auditTerminalAction(req, 'memory:predictions', 'ok');
res.json(intelligenceStore.listPredictions({
state: req.query.state || null,
limit: req.query.limit || 25,
}));
});
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') {
return res.json({ ok: true, action, health: buildHealth() });
auditTerminalAction(req, 'status', 'ok');
return res.json({ ok: true, action, status: 'ok', health: buildHealth() });
}
if (action === 'brief') {
if (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' });
return res.json({ ok: true, action, text: buildBrief(currentData) });
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 triggerSweep(res);
if (action === 'memory') {
auditTerminalAction(req, 'memory', 'ok');
return res.json({
ok: true,
action,
memory: intelligenceStore.status(),
recentEvents: intelligenceStore.queryMemory({ q: req.body?.q || '', limit: 8 }).results,
predictions: intelligenceStore.listPredictions({ limit: 8 }).predictions,
});
}
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'sweep'] });
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', 'memory', 'sweep'], actions: ['status', 'brief', 'memory', 'sweep'] });
});
// API: available locales
@@ -344,26 +383,114 @@ 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;
return Number.isFinite(ms) ? ms : null;
}
function canRunTerminalAction(req) {
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) return token === config.sweepToken;
return Boolean(config.terminalActionsEnabled || local);
}
function triggerSweep(res) {
if (sweepInProgress) return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
return res.status(202).json({ ok: true, status: 'accepted' });
}
function getLLMStatus() {
if (!config.llm.provider) return { state: 'disabled' };
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
@@ -407,7 +534,8 @@ function buildHealth() {
llm: getLLMStatus(),
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
terminalActionsEnabled: config.terminalActionsEnabled,
terminalActionsTokenRequired: !!config.sweepToken,
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
memory: intelligenceStore.status(),

82
test/adsb.test.mjs Normal file
View File

@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
async function withFetch(mockFetch, fn) {
const originalFetch = globalThis.fetch;
const originalAdsbKey = process.env.ADSB_API_KEY;
const originalRapidKey = process.env.RAPIDAPI_KEY;
globalThis.fetch = mockFetch;
delete process.env.ADSB_API_KEY;
delete process.env.RAPIDAPI_KEY;
try {
return await fn();
} finally {
globalThis.fetch = originalFetch;
if (originalAdsbKey === undefined) delete process.env.ADSB_API_KEY;
else process.env.ADSB_API_KEY = originalAdsbKey;
if (originalRapidKey === undefined) delete process.env.RAPIDAPI_KEY;
else process.env.RAPIDAPI_KEY = originalRapidKey;
}
}
function jsonResponse(payload, ok = true, status = 200) {
return {
ok,
status,
headers: { get: () => 'application/json' },
text: async () => JSON.stringify(payload),
};
}
test('ADS-B reports disabled when no key is configured and public feed fails', async () => {
await withFetch(async () => jsonResponse({ error: 'blocked' }, false, 403), async () => {
const { briefing } = await import('../apis/sources/adsb.mjs');
const data = await briefing();
assert.equal(data.status, 'disabled');
assert.match(data.error, /ADSB_API_KEY|RAPIDAPI_KEY/);
assert.equal(data.militaryAircraft.length, 0);
});
});
test('ADS-B reports degraded when RapidAPI and public feed fail', async () => {
await withFetch(async () => jsonResponse({ error: 'unavailable' }, false, 503), async () => {
process.env.ADSB_API_KEY = 'test-key';
const { briefing } = await import('../apis/sources/adsb.mjs');
const data = await briefing();
assert.equal(data.status, 'degraded');
assert.match(data.error, /providers returned no usable/);
assert.equal(data.failures.length, 2);
});
});
test('ADS-B returns live RapidAPI military aircraft payloads', async () => {
await withFetch(async () => jsonResponse({
ac: [{
hex: 'AE1234',
flight: 'RCH123',
t: 'KC135',
lat: 50,
lon: 8,
mil: true,
}],
}), async () => {
process.env.ADSB_API_KEY = 'test-key';
const { briefing } = await import('../apis/sources/adsb.mjs');
const data = await briefing();
assert.equal(data.status, 'live');
assert.equal(data.provider, 'rapidapi');
assert.equal(data.totalMilitary, 1);
assert.equal(data.militaryAircraft[0].callsign, 'RCH123');
});
});
test('runSource treats disabled source status as degraded health', async () => {
const { runSource } = await import('../apis/briefing.mjs');
const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' }));
assert.equal(result.status, 'degraded');
assert.equal(result.error, null);
});

View File

@@ -101,6 +101,46 @@ test('safeFetchText returns text and byte count', async () => {
}
});
test('intelligence store defines durable memory and prediction lifecycle tables', () => {
const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8');
assert.match(store, /CREATE TABLE IF NOT EXISTS events/);
assert.match(store, /stable_id TEXT NOT NULL UNIQUE/);
assert.match(store, /hypothesis TEXT/);
assert.match(store, /evidence_json TEXT/);
assert.match(store, /outcome_state TEXT DEFAULT 'open'/);
assert.match(store, /evaluatePredictions/);
assert.match(store, /queryMemory/);
assert.match(store, /listPredictions/);
});
test('server exposes memory-backed query APIs and dashboard memory action', () => {
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
assert.match(server, /\/api\/memory\/search/);
assert.match(server, /\/api\/memory\/predictions/);
assert.match(server, /action === 'memory'/);
assert.match(html, /runTerminalAction\('memory'\)/);
});
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/);
});
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('server dashboard shell does not embed an operational snapshot', () => {
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
assert.match(html, /let D = createDashboardShellData\(\);/);