fix: Tech Readiness toggle, Crypto top 10, FIRMS API key check (#1132, #979, #997) (#1135)

* fix: three panel issues — Tech Readiness toggle, Crypto top 10, FIRMS key check

1. #1132 — Add tech-readiness to FULL_PANELS so it appears in the
   Settings toggle list for Full/Geopolitical variant users.

2. #979 — Expand crypto panel from 4 coins to top 10 by market cap
   (BTC, ETH, USDT, BNB, SOL, XRP, USDC, ADA, DOGE, TRX) across
   client config, server metadata, CoinPaprika fallback map, and
   seed script.

3. #997 — Check isFeatureAvailable('nasaFirms') before loading FIRMS
   data. When the API key is missing, show a clear "not configured"
   message instead of the generic "No fire data available".

Closes #1132, closes #979, closes #997

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace stablecoins with AVAX/LINK, remove duplicate key, revert FIRMS change

- Replace USDT/USDC (stablecoins pegged ~$1) with AVAX and LINK
- Remove duplicate 'usd-coin' key in COINPAPRIKA_ID_MAP
- Add CoinPaprika fallback IDs for avalanche-2 and chainlink
- Revert FIRMS API key gating (handled differently now)
- Add sync comments across the 3 crypto config locations

* fix: update AIS relay + seed CoinPaprika fallback for all 10 coins

The AIS relay (primary seeder) still had the old 4-coin list.
The seed script's CoinPaprika fallback map was also missing the
new coins. Both now have all 10 entries.

* refactor: DRY crypto config into shared/crypto.json

Single source of truth for crypto IDs, metadata, and CoinPaprika
fallback mappings. All 4 consumers now import from shared/crypto.json:
- src/config/markets.ts (client)
- server/worldmonitor/market/v1/_shared.ts (server)
- scripts/seed-crypto-quotes.mjs (seed script)
- scripts/ais-relay.cjs (primary relay seeder)

Adding a new coin now requires editing only shared/crypto.json.

* chore: fix pre-existing markdown lint errors in README.md

Add blank lines between headings and lists per MD022/MD032 rules.

* fix: correct CoinPaprika XRP mapping and add crypto config test

- Fix xrp-ripple → xrp-xrp (current CoinPaprika id)
- Add tests/crypto-config.test.mjs: validates every coin has meta,
  coinpaprika mapping, unique symbols, no stablecoins, and valid
  id format — bad fallback ids now fail fast

* test: validate CoinPaprika ids against live API

The regex-only check wouldn't have caught the xrp-ripple typo.
New test fetches /v1/coins from CoinPaprika and asserts every
configured id exists. Gracefully skips if API is unreachable.

* fix(test): handle network failures in CoinPaprika API validation

Wrap fetch in try-catch so DNS failures, timeouts, and rate limits
skip gracefully instead of failing the test suite.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
Nicolas Dos Santos
2026-03-07 06:23:32 -08:00
committed by GitHub
parent 2d1163153d
commit 7b9426299d
7 changed files with 112 additions and 36 deletions

View File

@@ -1296,9 +1296,10 @@ async function seedEtfFlows() {
}
// Crypto Quotes — CoinGecko → CoinPaprika fallback
const CRYPTO_IDS = ['bitcoin', 'ethereum', 'solana', 'ripple'];
const CRYPTO_META = { bitcoin: { name: 'Bitcoin', symbol: 'BTC' }, ethereum: { name: 'Ethereum', symbol: 'ETH' }, solana: { name: 'Solana', symbol: 'SOL' }, ripple: { name: 'XRP', symbol: 'XRP' } };
const CRYPTO_PAPRIKA_MAP = { bitcoin: 'btc-bitcoin', ethereum: 'eth-ethereum', solana: 'sol-solana', ripple: 'xrp-ripple' };
const _cryptoCfg = require('../shared/crypto.json');
const CRYPTO_IDS = _cryptoCfg.ids;
const CRYPTO_META = _cryptoCfg.meta;
const CRYPTO_PAPRIKA_MAP = _cryptoCfg.coinpaprika;
const CRYPTO_SEED_TTL = 3600; // 1h
async function fetchCryptoCoinPaprika() {

View File

@@ -1,19 +1,18 @@
#!/usr/bin/env node
import { createRequire } from 'module';
import { loadEnvFile, CHROME_UA, runSeed, sleep } from './_seed-utils.mjs';
const require = createRequire(import.meta.url);
const cryptoConfig = require('../shared/crypto.json');
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'market:crypto:v1';
const CACHE_TTL = 3600; // 1 hour
const CRYPTO_IDS = ['bitcoin', 'ethereum', 'solana', 'ripple'];
const CRYPTO_META = {
bitcoin: { name: 'Bitcoin', symbol: 'BTC' },
ethereum: { name: 'Ethereum', symbol: 'ETH' },
solana: { name: 'Solana', symbol: 'SOL' },
ripple: { name: 'XRP', symbol: 'XRP' },
};
const CRYPTO_IDS = cryptoConfig.ids;
const CRYPTO_META = cryptoConfig.meta;
async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept: 'application/json', 'User-Agent': CHROME_UA }) {
for (let i = 0; i < maxAttempts; i++) {
@@ -33,12 +32,7 @@ async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept:
throw new Error('CoinGecko rate limit exceeded after retries');
}
const COINPAPRIKA_ID_MAP = {
bitcoin: 'btc-bitcoin',
ethereum: 'eth-ethereum',
solana: 'sol-solana',
ripple: 'xrp-ripple',
};
const COINPAPRIKA_ID_MAP = cryptoConfig.coinpaprika;
async function fetchFromCoinGecko() {
const ids = CRYPTO_IDS.join(',');

View File

@@ -2,6 +2,7 @@
* Shared helpers, types, and constants for the market service handler RPCs.
*/
import { CHROME_UA, yahooGate } from '../../../_shared/constants';
import cryptoConfig from '../../../../shared/crypto.json';
// ========================================================================
// Relay helpers (Railway proxy for Yahoo when Vercel IPs are rate-limited)
@@ -69,13 +70,7 @@ export const YAHOO_ONLY_SYMBOLS = new Set([
'GC=F', 'CL=F', 'NG=F', 'SI=F', 'HG=F',
]);
// Known crypto IDs and their metadata
export const CRYPTO_META: Record<string, { name: string; symbol: string }> = {
bitcoin: { name: 'Bitcoin', symbol: 'BTC' },
ethereum: { name: 'Ethereum', symbol: 'ETH' },
solana: { name: 'Solana', symbol: 'SOL' },
ripple: { name: 'XRP', symbol: 'XRP' },
};
export const CRYPTO_META: Record<string, { name: string; symbol: string }> = cryptoConfig.meta;
// ========================================================================
// Types
@@ -273,12 +268,9 @@ export async function fetchCoinGeckoMarkets(
// CoinPaprika fallback fetcher
// ========================================================================
// CoinGecko ID → CoinPaprika ID mapping
// CoinGecko ID → CoinPaprika ID mapping (shared ids + stablecoin-specific)
const COINPAPRIKA_ID_MAP: Record<string, string> = {
bitcoin: 'btc-bitcoin',
ethereum: 'eth-ethereum',
solana: 'sol-solana',
ripple: 'xrp-ripple',
...cryptoConfig.coinpaprika,
tether: 'usdt-tether',
'usd-coin': 'usdc-usd-coin',
dai: 'dai-dai',

31
shared/crypto.json Normal file
View File

@@ -0,0 +1,31 @@
{
"ids": [
"bitcoin", "ethereum", "binancecoin", "solana",
"ripple", "cardano", "dogecoin", "tron",
"avalanche-2", "chainlink"
],
"meta": {
"bitcoin": { "name": "Bitcoin", "symbol": "BTC" },
"ethereum": { "name": "Ethereum", "symbol": "ETH" },
"binancecoin": { "name": "BNB", "symbol": "BNB" },
"solana": { "name": "Solana", "symbol": "SOL" },
"ripple": { "name": "XRP", "symbol": "XRP" },
"cardano": { "name": "Cardano", "symbol": "ADA" },
"dogecoin": { "name": "Dogecoin", "symbol": "DOGE" },
"tron": { "name": "TRON", "symbol": "TRX" },
"avalanche-2": { "name": "Avalanche", "symbol": "AVAX" },
"chainlink": { "name": "Chainlink", "symbol": "LINK" }
},
"coinpaprika": {
"bitcoin": "btc-bitcoin",
"ethereum": "eth-ethereum",
"binancecoin": "bnb-binance-coin",
"solana": "sol-solana",
"ripple": "xrp-xrp",
"cardano": "ada-cardano",
"dogecoin": "doge-dogecoin",
"tron": "trx-tron",
"avalanche-2": "avax-avalanche",
"chainlink": "link-chainlink"
}
}

View File

@@ -1,4 +1,5 @@
import type { Sector, Commodity, MarketSymbol } from '@/types';
import cryptoConfig from '../../shared/crypto.json';
export const SECTORS: Sector[] = [
{ symbol: 'XLK', name: 'Tech' },
@@ -55,11 +56,5 @@ export const MARKET_SYMBOLS: MarketSymbol[] = [
{ symbol: 'BAC', name: 'BofA', display: 'BAC' },
];
export const CRYPTO_IDS = ['bitcoin', 'ethereum', 'solana', 'ripple'] as const;
export const CRYPTO_MAP: Record<string, { name: string; symbol: string }> = {
bitcoin: { name: 'Bitcoin', symbol: 'BTC' },
ethereum: { name: 'Ethereum', symbol: 'ETH' },
solana: { name: 'Solana', symbol: 'SOL' },
ripple: { name: 'XRP', symbol: 'XRP' },
};
export const CRYPTO_IDS = cryptoConfig.ids as readonly string[];
export const CRYPTO_MAP: Record<string, { name: string; symbol: string }> = cryptoConfig.meta;

View File

@@ -58,6 +58,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },
'tech-readiness': { name: 'Tech Readiness Index', enabled: true, priority: 2 },
'world-clock': { name: 'World Clock', enabled: true, priority: 2 },
};

View File

@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const crypto = require('../shared/crypto.json');
describe('shared/crypto.json integrity', () => {
it('every id in ids has a meta entry', () => {
for (const id of crypto.ids) {
assert.ok(crypto.meta[id], `missing meta for "${id}"`);
assert.ok(crypto.meta[id].name, `missing meta.name for "${id}"`);
assert.ok(crypto.meta[id].symbol, `missing meta.symbol for "${id}"`);
}
});
it('every id in ids has a coinpaprika mapping', () => {
for (const id of crypto.ids) {
assert.ok(crypto.coinpaprika[id], `missing coinpaprika mapping for "${id}"`);
}
});
it('coinpaprika ids follow the symbol-name pattern', () => {
for (const [geckoId, paprikaId] of Object.entries(crypto.coinpaprika)) {
assert.match(paprikaId, /^[a-z0-9]+-[a-z0-9-]+$/, `bad coinpaprika id format for "${geckoId}": "${paprikaId}"`);
}
});
it('coinpaprika ids exist on CoinPaprika API', async () => {
let coins;
try {
const resp = await fetch('https://api.coinpaprika.com/v1/coins', {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) { console.log(` skipping: CoinPaprika API returned ${resp.status}`); return; }
coins = await resp.json();
} catch (err) {
console.log(` skipping: CoinPaprika unreachable (${err.code || err.message})`);
return;
}
const validIds = new Set(coins.map((c) => c.id));
const invalid = [];
for (const [geckoId, paprikaId] of Object.entries(crypto.coinpaprika)) {
if (!validIds.has(paprikaId)) invalid.push(`${geckoId}${paprikaId}`);
}
assert.equal(invalid.length, 0, `invalid CoinPaprika ids:\n ${invalid.join('\n ')}`);
});
it('symbols are unique', () => {
const symbols = Object.values(crypto.meta).map((m) => m.symbol);
assert.equal(new Set(symbols).size, symbols.length, `duplicate symbols: ${symbols}`);
});
it('no stablecoins in the top-coins list', () => {
const stableSymbols = new Set(['USDT', 'USDC', 'DAI', 'FDUSD', 'USDE', 'TUSD', 'BUSD']);
for (const id of crypto.ids) {
const sym = crypto.meta[id]?.symbol;
assert.ok(!stableSymbols.has(sym), `stablecoin "${sym}" (${id}) should not be in top-coins list`);
}
});
});