diff --git a/.env.example b/.env.example index a862e47..a5c12b1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Intelligence Terminal / Crucix configuration +# Intelligence Terminal configuration # Copy to .env. Keep comments on separate lines; Docker env_file treats inline comments as values. # Server @@ -36,6 +36,8 @@ ACLED_EMAIL= ACLED_PASSWORD= CLOUDFLARE_API_TOKEN= BLS_API_KEY= +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= # Telegram bot and alerts TELEGRAM_BOT_TOKEN= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9587c6f..8a52d51 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @calesthio +* @MrSphay diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b2c1cf5..71b43bc 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security report - url: mailto:celesthioailabs@gmail.com + url: https://git.wilkensxl.de/MrSphay/intelligence-terminal about: Report security issues privately instead of opening a public issue. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index b76a0fc..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Build & Publish Docker Image - -on: - push: - branches: [master] - tags: ['v*'] - pull_request: - branches: [master] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - ${{ vars.DOCKERHUB_ENABLED == 'true' && format('{0}/{1}', secrets.DOCKERHUB_USERNAME, 'crucix') || '' }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix= - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 729b7d1..3957b86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Crucix +# Contributing to Intelligence Terminal -Crucix moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's direction. +Intelligence Terminal moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's private home-server deployment direction. ## What Contributions Are Most Helpful diff --git a/README.md b/README.md index 4bf1ab4..2c3e0db 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@
-# Crucix +# Intelligence Terminal -**Your own intelligence terminal. 27 sources. One command. Zero cloud.** +**Self-hosted intelligence dashboard. 27 open sources. Docker-first. No telemetry.** -## [Visit The Live Site: crucix.live](https://www.crucix.live/) - -[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/) -[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/) +[![Gitea Repository](https://img.shields.io/badge/source-Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white)](https://git.wilkensxl.de/MrSphay/intelligence-terminal) +[![Docker Image](https://img.shields.io/badge/image-Gitea%20Registry-0b1220?style=for-the-badge&logo=docker&logoColor=white)](https://git.wilkensxl.de/MrSphay/-/packages/container/intelligence-terminal/latest) [![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start) [![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE) @@ -15,12 +13,7 @@ [![Sources](https://img.shields.io/badge/OSINT%20sources-27-cyan)](#data-sources-27) [![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](#docker) -**Enter The Signal Network** - -[![Signal Wire](https://img.shields.io/badge/Signal%20Wire-%40crucixmonitor-111111?style=for-the-badge&logo=x&logoColor=white)](https://x.com/crucixmonitor) -[![Ops Room](https://img.shields.io/badge/Ops%20Room-Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ChVy7SF4) - -![Crucix Dashboard](docs/dashboard.png) +![Intelligence Terminal Dashboard](docs/dashboard.png)
More screenshots @@ -37,22 +30,24 @@
-> **Live website:** [https://www.crucix.live/](https://www.crucix.live/) -> Explore the public demo first, then clone the repo to run Crucix locally. +> **Supported deployment:** private home-server or lab deployment through Docker, Dockge, Pangolin, or local Node.js. +> Runtime data stays in your configured `runs/` volume and API keys are operator-owned. +> **Source:** [git.wilkensxl.de/MrSphay/intelligence-terminal](https://git.wilkensxl.de/MrSphay/intelligence-terminal) +> Pull the image or clone the repository to run Intelligence Terminal on your own infrastructure. -Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard. +Intelligence Terminal pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds in parallel, every 15 minutes, and renders everything on a single self-contained dashboard. -Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep. +Hook it up to an LLM and it becomes a **two-way intelligence assistant**: pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating trade ideas grounded in real cross-domain data. -Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack. +Run it locally with Node.js or pull the published Docker image for a home-server deployment. No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running. ## Token / Asset Warning > [!WARNING] -> **Crucix has not launched any official token, coin, NFT, airdrop, presale, or other blockchain-based asset.** -> Any token or digital asset using the Crucix name, logo, or branding is not affiliated with or endorsed by Crucix. +> **Intelligence Terminal has not launched any official token, coin, NFT, airdrop, presale, or other blockchain-based asset.** +> Any token or digital asset using the Intelligence Terminal or Crucix name, logo, or branding is not affiliated with or endorsed by this project. > Do not buy it, promote it, connect a wallet to claim it, sign transactions, or send funds based on third-party posts, DMs, or websites. --- @@ -61,7 +56,7 @@ No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're runn Most of the world's real-time intelligence — satellite imagery, radiation levels, conflict events, economic indicators, flight tracking, maritime activity — is publicly available. It's just scattered across dozens of government APIs, research institutions, and open data feeds that nobody has time to check individually. -Crucix brings it all into one place. Not behind a paywall, not locked in an enterprise platform, not requiring a security clearance. Just open data, aggregated and cross-correlated on your own machine, updated every 15 minutes. +Intelligence Terminal brings it all into one place. Not behind a paywall, not locked in an enterprise platform, not requiring a security clearance. Just open data, aggregated and cross-correlated on your own machine, updated every 15 minutes. It was built for anyone who wants to understand what's actually happening in the world right now — researchers, journalists, traders, OSINT analysts, or just curious people who believe access to information shouldn't depend on your budget. @@ -71,8 +66,8 @@ It was built for anyone who wants to understand what's actually happening in the ```bash # 1. Clone the repo -git clone https://github.com/calesthio/Crucix.git -cd Crucix +git clone https://git.wilkensxl.de/MrSphay/intelligence-terminal.git +cd intelligence-terminal # 2. Install dependencies (just Express) npm install @@ -190,6 +185,39 @@ For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-ter 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`. +#### 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: + +- Middle East energy shock +- Macro stress spillover +- Regional escalation risk + +Enable or add scenarios by editing `runs/scenarios.json`: + +```json +{ + "version": 1, + "scenarios": [ + { + "id": "middle-east-energy-shock", + "enabled": true, + "name": "Middle East energy shock", + "description": "Energy supply risk building from regional conflict.", + "regions": ["Middle East", "Iran", "Strait of Hormuz"], + "categories": ["osint", "energy", "maritime"], + "keywords": ["missile", "strike", "hormuz", "oil"], + "thresholds": { "watching": 2, "building": 4, "confirmed": 7 }, + "invalidation": "WTI normalizes and urgent regional signals fade." + } + ] +} +``` + +Malformed scenario config degrades safely: sweeps continue and the dashboard shows the watchlist as a config issue. Scenario state is persisted in `runs/scenario-state.json`; delete that file to reset state transitions without deleting definitions. + +Scenario states are `dormant`, `watching`, `building`, and `confirmed`. The dashboard shows active scenario state, confidence, score, and recent trigger time. Briefings include a `Scenario Watchlist` section when one or more scenarios change state. + #### Build And Publish Your Gitea Image ```bash @@ -248,7 +276,7 @@ The server runs a sweep cycle every 15 minutes (configurable). Each cycle: 6. Pushes update to all connected browsers via SSE ### Telegram Bot (Two-Way) -Crucix doubles as an interactive Telegram bot. Beyond sending alerts, it responds to commands directly from your chat: +Intelligence Terminal doubles as an interactive Telegram bot. Beyond sending alerts, it responds to commands directly from your chat: | Command | What It Does | |---------|-------------| @@ -265,7 +293,7 @@ This requires `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` in `.env`. The bot pol ### Discord Bot (Two-Way) -Crucix also supports Discord as a full-featured bot with slash commands and rich embed alerts. It mirrors the Telegram bot's capabilities with Discord-native formatting. +Intelligence Terminal also supports Discord as a full-featured bot with slash commands and rich embed alerts. It mirrors the Telegram bot's capabilities with Discord-native formatting. | Command | What It Does | |---------|-------------| @@ -280,7 +308,7 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye **Webhook fallback:** If you don't want to run a full bot, set `DISCORD_WEBHOOK_URL` instead. This enables one-way alerts (no slash commands) with zero dependencies — no `discord.js` needed. -**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. +**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Intelligence Terminal automatically falls back to webhook-only mode. ### Optional LLM Layer Connect cloud or local OpenAI-compatible LLM providers for enhanced analysis: @@ -330,6 +358,9 @@ These three unlock the most valuable economic and satellite data. Each takes abo | `ACLED_EMAIL` + `ACLED_PASSWORD` | Armed conflict event data | [acleddata.com/register](https://acleddata.com/register/) — free, OAuth2. `ACLED_USER` / `ACLED_USERNAME` are accepted as email aliases | | `AISSTREAM_API_KEY` | Maritime AIS vessel tracking | [aisstream.io](https://aisstream.io/) — free | | `ADSB_API_KEY` | Unfiltered flight tracking | [RapidAPI](https://rapidapi.com/adsbexchange/api/adsbexchange-com1) — ~$10/mo | +| `REDDIT_CLIENT_ID` + `REDDIT_CLIENT_SECRET` | Reddit social sentiment | [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps/) — create a script app | + +Reddit is OAuth-only in this fork. If the Reddit credentials are missing or rejected, the Reddit source is reported as degraded and no unauthenticated `reddit.com/.../hot.json` fallback is used. ### LLM Provider (optional, for AI-enhanced ideas) @@ -378,14 +409,14 @@ Alerts work with or without an LLM on both Telegram and Discord. With an LLM con ### Without Any Keys -Crucix still works with zero API keys. 18+ sources require no authentication at all. Sources that need keys return structured errors and the rest of the sweep continues normally. +Intelligence Terminal still works with zero API keys. 18+ sources require no authentication at all. Sources that need keys return structured errors and the rest of the sweep continues normally. --- ## Architecture ``` -crucix/ +intelligence-terminal/ ├── server.mjs # Express dev server (SSE, auto-refresh, LLM, bot commands) ├── crucix.config.mjs # Configuration with env var overrides + delta thresholds ├── diag.mjs # Diagnostic script — run if server fails to start @@ -574,7 +605,7 @@ This tests every import one by one, checks your Node.js version, and verifies po **3. Check if port 3117 is already in use:** -A previous Crucix instance may still be running in the background. +A previous Intelligence Terminal instance may still be running in the background. ```powershell # Windows PowerShell @@ -596,7 +627,7 @@ Then try starting again. You can also change the port by setting `PORT=3118` in ```bash node --version ``` -Crucix requires Node.js 22 or later. If you have an older version, download the latest LTS from [nodejs.org](https://nodejs.org/). +Intelligence Terminal requires Node.js 22 or later. If you have an older version, download the latest LTS from [nodejs.org](https://nodejs.org/). ### Dashboard shows empty panels after first start @@ -606,11 +637,11 @@ This is normal — the first sweep takes 30–60 seconds to query all 27 sources Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`. -OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Crucix does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep. +OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Intelligence Terminal does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep. ### Telegram bot not responding to commands -Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot/getMe`. +Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see Telegram alert and bot polling startup lines in the server logs. If not, double-check your token with `curl https://api.telegram.org/bot/getMe`. ### Discord bot not responding to slash commands @@ -641,29 +672,21 @@ To update them: run the dashboard, wait for a sweep to complete, then use your b ## Contributing -Found a bug? Want to add a 28th source? PRs welcome. Each source is a standalone module in `apis/sources/` — just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`. +Found a bug? Want to add a 28th source? PRs welcome. Each source is a standalone module in `apis/sources/` - just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`. -If you find this useful, a star helps others find it too. +If you find this useful, a star on the Gitea repository helps other operators find it too. For contribution guidelines, review expectations, and source-add rules, see `CONTRIBUTING.md`. For security reports, see `SECURITY.md`. ## Contact -For partnerships, integrations, or other non-issue inquiries, you can reach me at `celesthioailabs@gmail.com`. +For bugs, feature requests, and integration ideas, use the Gitea issue tracker so discussion stays visible and actionable: -For bugs and feature requests, please use GitHub Issues so discussion stays visible and actionable. +https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues ---- +## Upstream And License -## Star History - - - - - - Star History Chart - - +Intelligence Terminal is an AGPL-3.0-only Crucix fork focused on Docker-first home-server operation, source health transparency, Gitea Registry delivery, and operator-owned deployments. Upstream project credit remains with the original Crucix project. --- diff --git a/SECURITY.md b/SECURITY.md index 9627c2f..aeb8014 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ ## Reporting a Vulnerability -If you discover a security issue in Crucix, please report it privately instead of opening a public GitHub issue. +If you discover a security issue in Intelligence Terminal, please report it privately instead of opening a public issue. -Email: `celesthioailabs@gmail.com` +Use the private security contact configured for this Gitea repository or contact the repository owner directly. Use a subject line like: -`[Crucix Security] short description` +`[Intelligence Terminal Security] short description` Please include: diff --git a/apis/sources/reddit.mjs b/apis/sources/reddit.mjs index 29606cf..c6d17e0 100644 --- a/apis/sources/reddit.mjs +++ b/apis/sources/reddit.mjs @@ -1,14 +1,15 @@ -// Reddit — social sentiment intelligence -// Reddit now requires OAuth for API access (public JSON API returns 403). -// Gracefully degrades when not authenticated. -// To enable: register an app at https://www.reddit.com/prefs/apps/ and set -// REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env +// Reddit social sentiment intelligence. +// Reddit API access requires OAuth. Runtime sweeps intentionally do not use +// unauthenticated reddit.com .json scraping because it is unreliable and not +// acceptable for production operation. import { safeFetch } from '../utils/fetch.mjs'; import '../utils/env.mjs'; function delay(ms) { return new Promise(r => setTimeout(r, ms)); } +const USER_AGENT = 'Crucix/2.0 intelligence-engine'; + const SUBREDDITS = [ 'worldnews', 'geopolitics', @@ -17,48 +18,95 @@ const SUBREDDITS = [ 'commodities', ]; -// Get OAuth token using client credentials flow (application-only) -async function getToken() { - const clientId = process.env.REDDIT_CLIENT_ID; - const clientSecret = process.env.REDDIT_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; +export function getRedditConfig(env = process.env) { + const clientId = env.REDDIT_CLIENT_ID || ''; + const clientSecret = env.REDDIT_CLIENT_SECRET || ''; + const missing = []; + if (!clientId) missing.push('REDDIT_CLIENT_ID'); + if (!clientSecret) missing.push('REDDIT_CLIENT_SECRET'); + return { + clientId, + clientSecret, + configured: missing.length === 0, + missing, + }; +} + +function credentialsMessage(missing) { + return `Reddit requires OAuth. Register a script app at https://www.reddit.com/prefs/apps/ and set ${missing.join(' and ')} in .env`; +} + +export async function getToken({ env = process.env, fetchImpl = globalThis.fetch } = {}) { + const config = getRedditConfig(env); + if (!config.configured) { + return { + ok: false, + status: 'no_credentials', + missing: config.missing, + error: 'missing_reddit_oauth_credentials', + message: credentialsMessage(config.missing), + }; + } try { - const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); - const res = await fetch('https://www.reddit.com/api/v1/access_token', { + const auth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64'); + const res = await fetchImpl('https://www.reddit.com/api/v1/access_token', { method: 'POST', headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Crucix/1.0 intelligence-engine', + 'User-Agent': USER_AGENT, }, body: 'grant_type=client_credentials', }); - if (!res.ok) return null; + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { + ok: false, + status: 'auth_failed', + error: `reddit_oauth_http_${res.status}`, + message: `Reddit OAuth token request failed with HTTP ${res.status}`, + detail: body.slice(0, 200), + }; + } + const data = await res.json(); - return data.access_token || null; - } catch { - return null; + if (!data.access_token) { + return { + ok: false, + status: 'auth_failed', + error: 'reddit_oauth_missing_access_token', + message: 'Reddit OAuth token response did not include an access token', + }; + } + return { ok: true, status: 'ok', token: data.access_token }; + } catch (e) { + return { + ok: false, + status: 'auth_failed', + error: 'reddit_oauth_request_failed', + message: e.message, + }; } } -// Fetch hot posts — tries OAuth first, then falls back to public endpoint export async function getHot(subreddit, opts = {}) { const { limit = 10, token = null } = opts; - if (token) { - // Use OAuth endpoint - return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, { - headers: { - 'Authorization': `Bearer ${token}`, - 'User-Agent': 'Crucix/1.0 intelligence-engine', - }, - }); + if (!token) { + return { + status: 'no_credentials', + error: 'reddit_oauth_required', + message: 'Reddit source requires OAuth; unauthenticated reddit.com .json scraping is disabled', + }; } - // Try public endpoint (may 403) - return safeFetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=${limit}&raw_json=1`, { - headers: { 'User-Agent': 'Crucix/1.0 intelligence-engine' }, + return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, { + source: 'Reddit', + headers: { + 'Authorization': `Bearer ${token}`, + 'User-Agent': USER_AGENT, + }, }); } @@ -74,29 +122,46 @@ function compactPost(child) { }; } -export async function briefing() { - const token = await getToken(); +export async function briefing(opts = {}) { + const { + env = process.env, + subreddits = SUBREDDITS, + delayMs = 1000, + fetchImpl = globalThis.fetch, + } = opts; + const tokenResult = await getToken({ env, fetchImpl }); - if (!token && !process.env.REDDIT_CLIENT_ID) { + if (!tokenResult.ok) { return { source: 'Reddit', timestamp: new Date().toISOString(), - status: 'no_key', - message: 'Reddit requires OAuth. Register at https://www.reddit.com/prefs/apps/ (script type), set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env', + status: tokenResult.status, + error: tokenResult.error, + message: tokenResult.message, + missing: tokenResult.missing || [], }; } const subredditResults = {}; - for (const sub of SUBREDDITS) { - const result = await getHot(sub, { limit: 10, token }); + const errors = []; + for (const sub of subreddits) { + const result = await getHot(sub, { limit: 10, token: tokenResult.token }); + if (result?.error) { + errors.push({ subreddit: sub, error: result.error }); + subredditResults[sub] = []; + if (delayMs > 0) await delay(delayMs); + continue; + } const children = result?.data?.children || []; subredditResults[sub] = children.map(compactPost).filter(Boolean); - await delay(token ? 1000 : 2000); + if (delayMs > 0) await delay(delayMs); } return { source: 'Reddit', timestamp: new Date().toISOString(), + status: errors.length > 0 ? 'degraded' : 'ok', + ...(errors.length > 0 ? { error: 'reddit_subreddit_fetch_failed', errors } : {}), subreddits: subredditResults, }; } diff --git a/apis/utils/fetch.mjs b/apis/utils/fetch.mjs index 47b17d3..ec8da38 100644 --- a/apis/utils/fetch.mjs +++ b/apis/utils/fetch.mjs @@ -42,6 +42,7 @@ export async function safeFetch(url, opts = {}) { let lastError; for (let i = 0; i <= retries; i++) { const started = Date.now(); + let metricRecorded = false; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -51,22 +52,29 @@ export async function safeFetch(url, opts = {}) { }); clearTimeout(timer); const status = res.status; - if (!res.ok) { - const body = await res.text().catch(() => ''); - recordFetchMetric({ url, source, ok: false, status, bytes: body.length, durationMs: Date.now() - started, error: `HTTP ${res.status}` }); - throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`); - } const text = await res.text(); - recordFetchMetric({ url, source, ok: true, status, bytes: text.length, durationMs: Date.now() - started }); + if (!res.ok) { + const error = `HTTP ${res.status}`; + recordFetchMetric({ url, source, ok: false, status, bytes: text.length, durationMs: Date.now() - started, error }); + metricRecorded = true; + throw new Error(`${error}: ${text.slice(0, 200)}`); + } const trimmed = text.trim(); const contentType = res.headers.get('content-type') || ''; if (contentType.includes('text/html') || trimmed.startsWith(' setTimeout(r, 2000 * (i + 1))); } @@ -79,6 +87,7 @@ export async function safeFetchText(url, opts = {}) { let lastError; for (let i = 0; i <= retries; i++) { const started = Date.now(); + let metricRecorded = false; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -89,11 +98,14 @@ export async function safeFetchText(url, opts = {}) { clearTimeout(timer); const text = await res.text(); recordFetchMetric({ url, source, ok: res.ok, status: res.status, bytes: text.length, durationMs: Date.now() - started, error: res.ok ? null : `HTTP ${res.status}` }); + metricRecorded = true; if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`); return { text, status: res.status, bytes: text.length }; } catch (e) { lastError = e; - recordFetchMetric({ url, source, ok: false, status: null, bytes: 0, durationMs: Date.now() - started, error: e.message }); + if (!metricRecorded) { + recordFetchMetric({ url, source, ok: false, status: null, bytes: 0, durationMs: Date.now() - started, error: e.message }); + } if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1))); } } diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 5607403..39f2c7e 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -1652,6 +1652,14 @@ function renderRight(){ deltaRows.push(`
${s.label||s.key}${s.from}→${s.to} (${val})
`); } const deltaHtml = hasDelta ? deltaRows.join('') : `
${t('delta.noChanges','No changes since last sweep')}
`; + const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4); + const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => ` +
+ ${s.name} ${(s.state||'dormant').toUpperCase()} +

${s.description || ''}

+
${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}
+
+ `).join('') : `
No active scenario watchlist items
`; document.getElementById('rightRail').innerHTML=`
@@ -1667,6 +1675,10 @@ function renderRight(){

${t('panels.crossSourceSignals','Cross-Source Signals')}

${t('badges.worldview','WORLDVIEW')}
${signals}
+
+

Scenario Watchlist

${D.scenarios?.available===false?'CONFIG':'LIVE'}
+ ${scenarioHtml} +
${mobile ? '' : buildOsintPanel('right-osint', 260)}

${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/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/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 7b68f9f..09bc405 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/acled-source.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", "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..18c4afd 100644 --- a/server.mjs +++ b/server.mjs @@ -18,6 +18,7 @@ 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 { evaluateScenarios } from './lib/scenarios.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = __dirname; @@ -447,6 +448,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 +501,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) { diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 2dcee45..6a39267 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -1,9 +1,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'; 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, @@ -11,9 +13,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; } @@ -34,3 +99,18 @@ test('safeFetchText returns text and byte count', async () => { globalThis.fetch = originalFetch; } }); + +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/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']); +});