24 Commits

Author SHA1 Message Date
c159c83a07 Merge pull request 'revert(ui): remove app-style dashboard redesign' (#44) from codex/revert-app-style-design into codex/production-intelligence-terminal
Some checks failed
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 6s
Build / test-and-image (push) Successful in 25s
Scheduled Security Scan / security-scan (push) Failing after 7s
Scheduled Repository Cleanup Check / cleanup-check (push) Successful in 8s
Scheduled Dependency Check / dependency-check (push) Successful in 11s
Reviewed-on: #44
2026-05-17 20:45:24 +00:00
MrSphay
a1d415e449 Revert "Merge pull request 'feat(ui): redesign dashboard in app-style shell' (#42) from codex/modrinth-app-redesign into codex/production-intelligence-terminal"
All checks were successful
Build / test-and-image (pull_request) Successful in 25s
Codex Template Compliance / template-compliance (pull_request) Successful in 4s
This reverts commit 9f2083a324, reversing
changes made to 1c2b48f588.
2026-05-17 22:35:12 +02:00
MrSphay
0f5f9c5f91 Revert "Merge pull request 'feat(ui): finalize app-style dashboard motion and QA' (#43) from codex/modrinth-app-finalization into codex/production-intelligence-terminal"
This reverts commit 096544f6e6, reversing
changes made to 9f2083a324.
2026-05-17 22:35:07 +02:00
096544f6e6 Merge pull request 'feat(ui): finalize app-style dashboard motion and QA' (#43) from codex/modrinth-app-finalization into codex/production-intelligence-terminal
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 6s
Build / test-and-image (push) Successful in 26s
Reviewed-on: #43
2026-05-17 20:08:03 +00:00
MrSphay
5a3dbc6252 feat(ui): redesign dashboard in app-style shell
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m4s
2026-05-17 21:54:36 +02:00
9f2083a324 Merge pull request 'feat(ui): redesign dashboard in app-style shell' (#42) from codex/modrinth-app-redesign into codex/production-intelligence-terminal
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
Build / test-and-image (push) Successful in 24s
Reviewed-on: #42
2026-05-17 19:04:46 +00:00
dd08ecaf27 Merge branch 'codex/production-intelligence-terminal' into codex/modrinth-app-redesign
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
2026-05-17 19:03:03 +00:00
MrSphay
bc354e7bc5 feat(ui): redesign dashboard in app-style shell
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 59s
2026-05-17 20:57:54 +02:00
1c2b48f588 Merge pull request 'fix: infer source fetch metrics' (#35) from codex/issue-22-source-fetch-instrumentation into codex/production-intelligence-terminal
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
Build / test-and-image (push) Successful in 27s
2026-05-17 18:53:45 +00:00
MrSphay
a590bf62c2 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:52:27 +02:00
6a9918bc98 Merge pull request 'fix: keep sse streams alive behind proxies' (#34) from codex/issue-17-sse-heartbeat 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 13s
Codex Template Compliance / template-compliance (push) Successful in 5s
2026-05-17 18:51:04 +00:00
MrSphay
4448f5931b Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 4s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	.env.example
#	README.md
#	crucix.config.mjs
#	test/fetch-utils.test.mjs
2026-05-17 20:49:14 +02:00
9b15913049 Merge pull request 'feat: extend memory prediction loop' (#32) from codex/issue-4-memory-prediction-loop into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 24s
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
2026-05-17 18:47:39 +00:00
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
e4834cd3cd Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:40:44 +02:00
MrSphay
0fbd8640ca Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 1m0s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:40:10 +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
MrSphay
c102017b16 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
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:37:21 +02:00
MrSphay
eefc1a4c77 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 53s
# Conflicts:
#	README.md
#	test/fetch-utils.test.mjs
2026-05-17 20:36:31 +02: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
2025ae09db fix: infer source fetch metrics
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 4s
Build / test-and-image (pull_request) Successful in 53s
2026-05-17 14:44:21 +02:00
MrSphay
446076cb84 fix: keep sse streams alive behind proxies
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 52s
2026-05-17 14:41:55 +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
8 changed files with 329 additions and 38 deletions

View File

@@ -10,6 +10,9 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
# LLM layer

View File

@@ -134,6 +134,9 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=https://intelligence.example.internal
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
LLM_PROVIDER=openrouter
@@ -185,9 +188,22 @@ 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`.
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.
#### Terminal Action Exposure
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`.
`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.
#### Memory And Prediction Loop
@@ -219,6 +235,22 @@ Retention, backup, and privacy expectations:
- 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.
#### Reverse Proxy SSE
The dashboard receives live sweep updates from `GET /events` using Server-Sent Events. The server sends `retry: 10000` reconnect guidance and lightweight heartbeat comments every `SSE_HEARTBEAT_INTERVAL_MS` milliseconds so reverse proxies do not close an otherwise idle stream between 15-minute sweeps.
Recommended proxy settings:
| Proxy | Setting |
| --- | --- |
| Pangolin / Traefik-style frontends | Keep response streaming enabled and set idle timeouts above `SSE_HEARTBEAT_INTERVAL_MS`. |
| Nginx | Disable proxy buffering for `/events`, keep `proxy_read_timeout` above the heartbeat interval, and preserve `Connection: keep-alive`. |
| Cloudflare-style proxies | Keep the heartbeat below common idle cutoffs; the default 25s is intentionally conservative. |
If you raise the heartbeat interval, keep it shorter than the lowest idle timeout in the proxy chain.
`/api/metrics` includes network health grouped by host and source/provider. Source modules should use `safeFetch(url, { source: 'SourceName' })`; when omitted, the shared helper infers a stable provider bucket from the URL host instead of grouping normal source traffic under `unknown`. Raw fetch exceptions are documented in [Source Fetch Instrumentation](docs/source-fetch-instrumentation.md).
#### Scenario Watchlist
Intelligence Terminal can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples:

View File

@@ -10,6 +10,44 @@ const fetchMetrics = {
recent: [],
};
const SOURCE_BY_HOST = [
[/api\.bls\.gov$/i, 'BLS'],
[/api\.fred\.stlouisfed\.org$/i, 'FRED'],
[/api\.eia\.gov$/i, 'EIA'],
[/api\.gdeltproject\.org$/i, 'GDELT'],
[/api\.weather\.gov$/i, 'NOAA'],
[/api\.open-notify\.org$/i, 'OpenNotify'],
[/opensky-network\.org$/i, 'OpenSky'],
[/firms\.modaps\.eosdis\.nasa\.gov$/i, 'FIRMS'],
[/api\.acleddata\.com$/i, 'ACLED'],
[/api\.reliefweb\.int$/i, 'ReliefWeb'],
[/receiverbook\.de$/i, 'KiwiSDR'],
[/safecast\.org$/i, 'Safecast'],
[/api\.patentsview\.org$/i, 'PatentsView'],
[/api\.trade\.gov$/i, 'Comtrade'],
[/api\.usaspending\.gov$/i, 'USASpending'],
[/api\.telegram\.org$/i, 'Telegram'],
[/oauth\.reddit\.com$/i, 'Reddit'],
[/reddit\.com$/i, 'Reddit'],
[/api\.bsky\.app$/i, 'Bluesky'],
[/api\.yahoo\.com$/i, 'YahooFinance'],
[/query\d?\.finance\.yahoo\.com$/i, 'YahooFinance'],
[/api\.cloudflare\.com$/i, 'CloudflareRadar'],
[/api\.opensanctions\.org$/i, 'OpenSanctions'],
[/home\.treasury\.gov$/i, 'Treasury'],
[/fiscaldata\.treasury\.gov$/i, 'Treasury'],
[/who\.int$/i, 'WHO'],
];
export function inferFetchSource(url) {
let host = 'unknown';
try { host = new URL(url).host.toLowerCase(); } catch { return 'unknown'; }
for (const [pattern, source] of SOURCE_BY_HOST) {
if (pattern.test(host)) return source;
}
return host;
}
function metricBucket(map, key) {
if (!map[key]) map[key] = { requests: 0, ok: 0, failed: 0, bytes: 0, lastStatus: null, lastError: null, lastMs: 0 };
return map[key];
@@ -38,7 +76,7 @@ export function getFetchMetrics() {
}
export async function safeFetch(url, opts = {}) {
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
const { timeout = 15000, retries = 1, headers = {}, source = inferFetchSource(url) } = opts;
let lastError;
for (let i = 0; i <= retries; i++) {
const started = Date.now();
@@ -79,11 +117,11 @@ export async function safeFetch(url, opts = {}) {
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
}
}
return { error: lastError?.message || 'Unknown error', source: url };
return { error: lastError?.message || 'Unknown error', source };
}
export async function safeFetchText(url, opts = {}) {
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
const { timeout = 15000, retries = 1, headers = {}, source = inferFetchSource(url) } = opts;
let lastError;
for (let i = 0; i <= retries; i++) {
const started = Date.now();

View File

@@ -26,7 +26,10 @@ 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),
sseHeartbeatIntervalMs: intEnv('SSE_HEARTBEAT_INTERVAL_MS', 25000),
llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok

View File

@@ -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})
});
@@ -1698,6 +1720,7 @@ function renderRight(){
<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

@@ -0,0 +1,21 @@
# Source Fetch Instrumentation
`safeFetch()` and `safeFetchText()` attribute requests to `/api/metrics.fetch.bySource`.
Rules:
- Prefer passing an explicit `source` option from source modules when the call has a clear Crucix source name.
- If `source` is omitted, the shared helper infers a stable provider name from the request host.
- Unknown hosts fall back to the lowercase host instead of the old `unknown` bucket.
- Raw `fetch()` calls should be limited to cases where the shared helper cannot represent the protocol cleanly.
Current raw-fetch exceptions:
| Area | Reason |
| --- | --- |
| OAuth/session handshakes | Token exchange calls often need custom form bodies, credential headers, or status-specific diagnostics. |
| Bot and alert delivery | Telegram/Discord alert calls are outbound operator notifications, not intelligence source health. |
| LLM providers | Provider clients already track model/provider status separately from source fetch health. |
| Dashboard browser calls | Browser-side `/api/*` and asset fetches are UI behavior, not source provider health. |
When adding a new intelligence source, use `safeFetch(url, { source: 'SourceName' })` unless there is a documented exception.

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 ===
@@ -292,7 +293,9 @@ app.get('/api/metrics', (req, res) => {
});
app.get('/api/memory/search', (req, res) => {
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
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,
@@ -300,7 +303,9 @@ app.get('/api/memory/search', (req, res) => {
});
app.get('/api/memory/predictions', (req, res) => {
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
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,
@@ -308,24 +313,33 @@ app.get('/api/memory/predictions', (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);
const guard = authorizeTerminalAction(req, res, 'sweep');
if (!guard.ok) return;
triggerSweepAction(req, res, 'sweep');
});
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.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 === 'memory') {
auditTerminalAction(req, 'memory', 'ok');
return res.json({
ok: true,
action,
@@ -335,11 +349,10 @@ app.post('/api/action', express.json(), async (req, res) => {
});
}
if (action === 'sweep') {
return triggerSweep(res);
}
if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'memory', '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
@@ -357,10 +370,24 @@ app.get('/events', (req, res) => {
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
});
res.write('retry: 10000\n');
res.write('data: {"type":"connected"}\n\n');
const heartbeatMs = Math.max(5000, config.sseHeartbeatIntervalMs || 25000);
const heartbeat = setInterval(() => {
try {
res.write(`: heartbeat ${new Date().toISOString()}\n\n`);
} catch {
clearInterval(heartbeat);
sseClients.delete(res);
}
}, heartbeatMs);
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(res);
});
});
function broadcast(data) {
@@ -370,26 +397,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 };
@@ -433,7 +548,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(),

View File

@@ -1,7 +1,7 @@
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 { safeFetch, safeFetchText, getFetchMetrics, inferFetchSource } from '../apis/utils/fetch.mjs';
import { formatStaleAlert, shouldSendStaleAlert } from '../lib/stale-alerts.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
@@ -101,6 +101,42 @@ test('safeFetchText returns text and byte count', async () => {
}
});
test('safeFetch attributes unlabelled requests to a stable provider source', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
text: async () => '{"observations":[]}',
});
try {
const data = await safeFetch('https://api.fred.stlouisfed.org/fred/series/observations?series_id=VIXCLS', { retries: 0 });
assert.deepEqual(data, { observations: [] });
const bucket = getFetchMetrics().bySource.FRED;
assert.ok(bucket.requests >= 1);
assert.equal(bucket.lastStatus, 200);
} finally {
globalThis.fetch = originalFetch;
}
});
test('inferFetchSource returns provider names and host fallback', () => {
assert.equal(inferFetchSource('https://api.bls.gov/publicAPI/v2/timeseries/data/CPI'), 'BLS');
assert.equal(inferFetchSource('https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC'), 'YahooFinance');
assert.equal(inferFetchSource('https://unknown.example.test/path'), 'unknown.example.test');
});
test('SSE endpoint sends reconnect guidance and clears heartbeat timer', () => {
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
const config = readFileSync(new URL('../crucix.config.mjs', import.meta.url), 'utf8');
assert.match(config, /sseHeartbeatIntervalMs/);
assert.match(server, /retry: 10000\\n/);
assert.match(server, /setInterval\(\(\) =>/);
assert.match(server, /: heartbeat/);
assert.match(server, /clearInterval\(heartbeat\)/);
assert.match(server, /X-Accel-Buffering/);
});
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/);
@@ -122,6 +158,25 @@ test('server exposes memory-backed query APIs and dashboard memory action', () =
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\(\);/);