Compare commits

..

6 Commits

Author SHA1 Message Date
8de9feda62 docs: record source degradation verification (#86)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 5s
Release Dry Run / release-dry-run (push) Successful in 15s
Build / test-and-image (push) Successful in 26s
2026-07-07 19:37:54 +00:00
9a67b160ef docs: record source degradation verification
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 25s
2026-07-07 21:36:00 +02:00
831cae1729 fix: harden degraded source handling (#85)
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 17s
Codex Template Compliance / template-compliance (push) Successful in 7s
Build / test-and-image (push) Successful in 34s
Closes #84.
2026-07-07 19:19:01 +00:00
62d9c680b7 fix: harden degraded source handling
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 53s
2026-07-07 21:16:24 +02:00
cd19c857f3 feat: make DAVE Telegram chat conversational (#83)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 6s
Release Dry Run / release-dry-run (push) Successful in 15s
Build / test-and-image (push) Successful in 30s
Closes #82.
2026-07-07 16:49:49 +00:00
8576a71b4f Merge pull request 'docs: record DAVE profile language fix' (#81) from codex/handoff-profile-language-enforcement into codex/production-intelligence-terminal
Some checks failed
Codex Template Compliance / template-compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Successful in 17s
Build / test-and-image (push) Successful in 29s
Scheduled Dependency Check / dependency-check (push) Failing after 13s
2026-07-06 07:20:17 +00:00
10 changed files with 200 additions and 33 deletions

View File

@@ -501,6 +501,14 @@ These three unlock the most valuable economic and satellite data. Each takes abo
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. 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.
Source resilience notes:
- GDELT web/news pulls are bounded so optional geo enrichment cannot consume the full sweep source budget.
- OFAC sanctions metadata is read through bounded XML snippets instead of downloading full sanctions XML files during every sweep.
- EPA RadNet uses shorter bounded requests and reports degraded state instead of blocking the sweep when the government endpoint is slow.
- BLS automatically retries without `BLS_API_KEY` when a configured key is rejected, while surfacing a configuration warning.
- ACLED `403` means the credentials authenticated but the account still needs ACLED terms/profile/API access completed.
- ADS-B and Reddit require credentials for their production-grade feeds; missing credentials are reported as degraded/disabled rather than silently faking live data.
### LLM Provider (optional, for AI-enhanced ideas) ### LLM Provider (optional, for AI-enhanced ideas)
Set `LLM_PROVIDER` to one of: `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok`. Set `LLM_PROVIDER` to one of: `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok`.

View File

@@ -61,12 +61,13 @@ export async function runSource(name, fn, ...args) {
const hasError = Boolean(data?.error); const hasError = Boolean(data?.error);
const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error']; const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
const isDegraded = hasError || degradedStatuses.includes(data?.status); const isDegraded = hasError || degradedStatuses.includes(data?.status);
const error = data?.error || data?.configWarning || data?.message || null;
return { return {
name, name,
status: isDegraded ? 'degraded' : 'ok', status: isDegraded ? 'degraded' : 'ok',
durationMs: Date.now() - start, durationMs: Date.now() - start,
data, data,
error: hasError ? data.error : null, error: isDegraded ? error : null,
}; };
} catch (e) { } catch (e) {
return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; return { name, status: 'error', durationMs: Date.now() - start, error: e.message };

View File

@@ -52,6 +52,11 @@ export async function getSeries(seriesIds, opts = {}) {
} }
} }
function isInvalidKeyResponse(resp) {
const message = Array.isArray(resp?.message) ? resp.message.join(' ') : String(resp?.message || resp?.error || '');
return Boolean(resp) && /key:.*invalid|invalid.*key|registrationkey/i.test(message);
}
// Extract the latest observation from a BLS series response // Extract the latest observation from a BLS series response
function latestFromSeries(seriesData) { function latestFromSeries(seriesData) {
if (!seriesData?.data?.length) return null; if (!seriesData?.data?.length) return null;
@@ -95,17 +100,25 @@ function momChange(seriesData) {
// Briefing — pull latest CPI, unemployment, payrolls // Briefing — pull latest CPI, unemployment, payrolls
export async function briefing(apiKey) { export async function briefing(apiKey) {
const seriesIds = Object.keys(SERIES); const seriesIds = Object.keys(SERIES);
const resp = await getSeries(seriesIds, { apiKey }); let resp = await getSeries(seriesIds, { apiKey });
let configWarning = null;
if (apiKey && isInvalidKeyResponse(resp)) {
configWarning = 'Configured BLS_API_KEY was rejected; retried with unauthenticated BLS v1 API.';
resp = await getSeries(seriesIds, { apiKey: null });
}
if (resp.error) { if (resp.error) {
return { source: 'BLS', error: resp.error, timestamp: new Date().toISOString() }; return { source: 'BLS', status: 'degraded', error: resp.error, configWarning, timestamp: new Date().toISOString() };
} }
if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) { if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) {
return { return {
source: 'BLS', source: 'BLS',
status: 'degraded',
error: resp.message?.[0] || 'BLS API returned no data', error: resp.message?.[0] || 'BLS API returned no data',
rawStatus: resp.status, rawStatus: resp.status,
configWarning,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
@@ -155,6 +168,7 @@ export async function briefing(apiKey) {
return { return {
source: 'BLS', source: 'BLS',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(configWarning ? { status: 'degraded', configWarning } : { status: 'ok' }),
indicators, indicators,
signals, signals,
}; };

View File

@@ -50,29 +50,29 @@ const THRESHOLDS = {
// Get recent RadNet analytical results (JSON) // Get recent RadNet analytical results (JSON)
export async function getAnalyticalResults(opts = {}) { export async function getAnalyticalResults(opts = {}) {
const { rows = 50, startRow = 0 } = opts; const { rows = 50, startRow = 0, timeout = 10000 } = opts;
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
// Get results filtered by state // Get results filtered by state
export async function getResultsByState(state, opts = {}) { export async function getResultsByState(state, opts = {}) {
const { rows = 25, startRow = 0 } = opts; const { rows = 25, startRow = 0, timeout = 8000 } = opts;
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
// Get results filtered by analyte type // Get results filtered by analyte type
export async function getResultsByAnalyte(analyte, opts = {}) { export async function getResultsByAnalyte(analyte, opts = {}) {
const { rows = 25, startRow = 0 } = opts; const { rows = 25, startRow = 0, timeout = 8000 } = opts;
const encoded = encodeURIComponent(analyte); const encoded = encodeURIComponent(analyte);
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ANA_TYPE/${encoded}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ANA_TYPE/${encoded}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
@@ -129,7 +129,20 @@ export async function briefing() {
const signals = []; const signals = [];
// Fetch recent analytical results (broad pull) // Fetch recent analytical results (broad pull)
const recentData = await getAnalyticalResults({ rows: 100 }); const recentData = await getAnalyticalResults({ rows: 80, timeout: 10000 });
if (recentData?.error) {
return {
source: 'EPA RadNet',
timestamp: new Date().toISOString(),
status: 'degraded',
error: recentData.error,
message: 'EPA RadNet broad analytical endpoint did not return current data within the source budget.',
readings: [],
signals: ['EPA RadNet unavailable — cannot assess official US radiation readings'],
monitoredAnalytes: KEY_ANALYTES,
thresholds: THRESHOLDS,
};
}
const recentRecords = Array.isArray(recentData) ? recentData : []; const recentRecords = Array.isArray(recentData) ? recentData : [];
// Compact all readings // Compact all readings
@@ -137,15 +150,21 @@ export async function briefing() {
readings.push(...allReadings); readings.push(...allReadings);
// Also try to pull key analytes specifically // Also try to pull key analytes specifically
const analyteResults = await Promise.all( const analyteResults = await Promise.allSettled(
['GROSS BETA', 'IODINE-131', 'CESIUM-137'].map(async analyte => { ['GROSS BETA', 'IODINE-131', 'CESIUM-137'].map(async analyte => {
const data = await getResultsByAnalyte(analyte, { rows: 20 }); const data = await getResultsByAnalyte(analyte, { rows: 10, timeout: 6000 });
const records = Array.isArray(data) ? data : []; const records = Array.isArray(data) ? data : [];
return { analyte, records: records.map(compactReading) }; return { analyte, records: records.map(compactReading) };
}) })
); );
for (const { analyte, records } of analyteResults) { const analyteErrors = [];
for (const item of analyteResults) {
if (item.status !== 'fulfilled') {
analyteErrors.push(item.reason?.message || 'unknown analyte fetch error');
continue;
}
const { records } = item.value;
// Add any records not already in our list // Add any records not already in our list
for (const r of records) { for (const r of records) {
if (!readings.some(existing => if (!readings.some(existing =>
@@ -196,6 +215,8 @@ export async function briefing() {
return { return {
source: 'EPA RadNet', source: 'EPA RadNet',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: analyteErrors.length ? 'degraded' : 'ok',
...(analyteErrors.length ? { error: 'epa_analyte_fetch_degraded', analyteErrors: analyteErrors.slice(0, 3) } : {}),
totalReadings: readings.length, totalReadings: readings.length,
readings: readings.slice(0, 50), // cap for briefing size readings: readings.slice(0, 50), // cap for briefing size
stateSummary, stateSummary,

View File

@@ -10,11 +10,13 @@ const BASE = 'https://api.gdeltproject.org/api/v2';
// Search recent global events/articles by keyword // Search recent global events/articles by keyword
export async function searchEvents(query = '', opts = {}) { export async function searchEvents(query = '', opts = {}) {
const { const {
mode = 'ArtList', // ArtList, TimelineVol, TimelineVolInfo, TimelineTone, TimelineLang, TimelineSourceCountry mode = 'artlist',
maxRecords = 75, maxRecords = 75,
timespan = '24h', // e.g. "24h", "7d", "3m" timespan = '24h', // e.g. "24h", "7d", "3m"
format = 'json', format = 'json',
sortBy = 'DateDesc', // DateDesc, DateAsc, ToneDesc, ToneAsc sortBy = 'datedesc',
timeout = 12000,
retries = 0,
} = opts; } = opts;
// If no query, use broad geopolitical terms // If no query, use broad geopolitical terms
@@ -28,7 +30,7 @@ export async function searchEvents(query = '', opts = {}) {
sort: sortBy, sort: sortBy,
}); });
return safeFetch(`${BASE}/doc/doc?${params}`); return safeFetch(`${BASE}/doc/doc?${params}`, { timeout, retries, source: 'GDELT' });
} }
// Get tone/sentiment timeline for a topic // Get tone/sentiment timeline for a topic
@@ -60,6 +62,8 @@ export async function geoEvents(query = '', opts = {}) {
timespan = '24h', timespan = '24h',
format = 'GeoJSON', format = 'GeoJSON',
maxPoints = 500, maxPoints = 500,
timeout = 8000,
retries = 0,
} = opts; } = opts;
const q = query || 'conflict OR military OR protest OR explosion'; const q = query || 'conflict OR military OR protest OR explosion';
@@ -71,7 +75,7 @@ export async function geoEvents(query = '', opts = {}) {
maxpoints: String(maxPoints), maxpoints: String(maxPoints),
}); });
return safeFetch(`${BASE}/geo/geo?${params}`); return safeFetch(`${BASE}/geo/geo?${params}`, { timeout, retries, source: 'GDELT' });
} }
// Compact article for briefing // Compact article for briefing
@@ -94,7 +98,7 @@ export async function briefing() {
// Single broad query to stay within rate limits // Single broad query to stay within rate limits
const all = await searchEvents( const all = await searchEvents(
'conflict OR military OR economy OR crisis OR war OR sanctions OR tariff OR strike OR outbreak', 'conflict OR military OR economy OR crisis OR war OR sanctions OR tariff OR strike OR outbreak',
{ maxRecords: 50, timespan: '24h' } { maxRecords: 50, timespan: '24h', timeout: 12000, retries: 0 }
); );
const articles = (all?.articles || []).map(compactArticle); const articles = (all?.articles || []).map(compactArticle);
@@ -108,7 +112,7 @@ export async function briefing() {
await delay(5500); await delay(5500);
let geoPoints = []; let geoPoints = [];
try { try {
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' }); const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h', timeout: 8000, retries: 0 });
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({ geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
lat: f.geometry.coordinates[1], lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0], lon: f.geometry.coordinates[0],

View File

@@ -2,8 +2,6 @@
// No auth required. Monitors the Specially Designated Nationals (SDN) list // No auth required. Monitors the Specially Designated Nationals (SDN) list
// and consolidated sanctions list for changes. // and consolidated sanctions list for changes.
import { safeFetch } from '../utils/fetch.mjs';
const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports'; const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports';
// SDN list endpoints // SDN list endpoints
@@ -42,21 +40,19 @@ function parseSDNMetadata(xml) {
// Fetch SDN list metadata (smaller initial chunk via timeout) // Fetch SDN list metadata (smaller initial chunk via timeout)
export async function getSDNMetadata() { export async function getSDNMetadata() {
// The full SDN XML is large; safeFetch will get the first 500 chars const data = await fetchXmlSnippet(SDN_XML_URL);
// which should include the header/publish date
const data = await safeFetch(SDN_XML_URL, { timeout: 20000 });
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
// Fetch advanced SDN data (includes more structured info) // Fetch advanced SDN data (includes more structured info)
export async function getSDNAdvanced() { export async function getSDNAdvanced() {
const data = await safeFetch(SDN_ADVANCED_URL, { timeout: 20000 }); const data = await fetchXmlSnippet(SDN_ADVANCED_URL);
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
// Fetch consolidated list metadata // Fetch consolidated list metadata
export async function getConsolidatedMetadata() { export async function getConsolidatedMetadata() {
const data = await safeFetch(CONS_ADVANCED_URL, { timeout: 20000 }); const data = await fetchXmlSnippet(CONS_ADVANCED_URL);
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
@@ -101,15 +97,13 @@ function parseRecentEntries(xml) {
// Briefing — report on sanctions list status and metadata // Briefing — report on sanctions list status and metadata
export async function briefing() { export async function briefing() {
const [sdnMeta, advancedMeta] = await Promise.all([ const [sdnMeta, advancedMeta, advancedSnippet] = await Promise.all([
getSDNMetadata(), getSDNMetadata(),
getSDNAdvanced(), getSDNAdvanced(),
fetchXmlSnippet(SDN_ADVANCED_URL, { maxBytes: 256_000 }),
]); ]);
// Try to extract any entries visible in the advanced data const sampleEntries = parseRecentEntries(advancedSnippet);
const sampleEntries = parseRecentEntries(
await safeFetch(SDN_ADVANCED_URL, { timeout: 25000 })
);
return { return {
source: 'OFAC Sanctions', source: 'OFAC Sanctions',
@@ -136,6 +130,46 @@ export async function briefing() {
}; };
} }
async function fetchXmlSnippet(url, opts = {}) {
const timeout = opts.timeout || 9000;
const maxBytes = opts.maxBytes || 128_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Crucix/2.0',
'Range': `bytes=0-${maxBytes - 1}`,
},
signal: controller.signal,
});
if (!res.ok && res.status !== 206) {
const body = await res.text().catch(() => '');
return { error: `HTTP ${res.status}: ${body.slice(0, 160)}` };
}
if (!res.body?.getReader) {
const text = await res.text();
return { rawText: text.slice(0, maxBytes) };
}
const reader = res.body.getReader();
const chunks = [];
let bytes = 0;
while (bytes < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
bytes += value.byteLength;
}
await reader.cancel().catch(() => {});
const buffer = Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), bytes);
return { rawText: buffer.toString('utf8', 0, Math.min(bytes, maxBytes)) };
} catch (error) {
return { error: error.name === 'AbortError' ? `OFAC snippet request timed out after ${timeout}ms` : error.message };
} finally {
clearTimeout(timer);
}
}
// Run standalone // Run standalone
if (process.argv[1]?.endsWith('ofac.mjs')) { if (process.argv[1]?.endsWith('ofac.mjs')) {
const data = await briefing(); const data = await briefing();

View File

@@ -2,6 +2,13 @@
Last updated: 2026-07-07 Last updated: 2026-07-07
## Source Degradation Handling
- Issue #84 tracks the DAVE diagnosis showing live health degraded because GDELT, OFAC, and EPA timed out; ACLED returned `403`; ADS-B and Reddit lacked credentials; BLS had an invalid configured key.
- Code-side fixes in progress on `codex/fix-source-degradation-handling`: GDELT requests are bounded and optional geo enrichment cannot consume the source budget; OFAC uses bounded XML snippet streaming; EPA RadNet uses shorter bounded requests and returns degraded state instead of blocking; BLS retries unauthenticated v1 when `BLS_API_KEY` is rejected and exposes a config warning through source health.
- Remaining non-code actions after deploy: ACLED account must accept terms/complete profile/API access for `403`; ADS-B needs `ADSB_API_KEY` or `RAPIDAPI_KEY`; Reddit needs `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET`; BLS key should be removed or replaced if the v1 fallback is acceptable.
- PR #85 merged as `831cae17295730ee4be0848098cbe9f52cabe716`; PR runs 981-982 and production runs 983-985 passed. The registry image was redeployed to Dockge. Live verification after the new sweep showed GDELT and OFAC no longer degraded; `BLS_API_KEY` was cleared in the Dockge `.env`, and BLS no longer appears in degraded health. Remaining degraded sources are ACLED `403`, ADS-B missing key, EPA RadNet table `404`, and Reddit missing OAuth.
## DAVE Conversational Telegram Delivery And Web Search ## DAVE Conversational Telegram Delivery And Web Search
- Issue #82 tracks operator feedback from 2026-07-07: proactive DAVE messages were too report-like, used visible Markdown markers, arrived as large text blocks, and needed a way to start web searches from chat. - Issue #82 tracks operator feedback from 2026-07-07: proactive DAVE messages were too report-like, used visible Markdown markers, arrived as large text blocks, and needed a way to start web searches from chat.

View File

@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs", "brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs", "diag": "node diag.mjs",
"test": "npm run test:unit", "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/llm-litellm.test.mjs test/llm-ideas.test.mjs test/telegram-chat.test.mjs test/terminal-agent.test.mjs test/dave-presence.test.mjs test/security-profile.test.mjs test/security-onboarding.test.mjs test/security-alert-policy.test.mjs test/intelligence-store.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs", "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/llm-litellm.test.mjs test/llm-ideas.test.mjs test/telegram-chat.test.mjs test/terminal-agent.test.mjs test/dave-presence.test.mjs test/security-profile.test.mjs test/security-onboarding.test.mjs test/security-alert-policy.test.mjs test/intelligence-store.test.mjs test/fetch-utils.test.mjs test/source-resilience.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs",
"compose:config": "docker compose config", "compose:config": "docker compose config",
"clean": "node scripts/clean.mjs", "clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start" "fresh-start": "npm run clean && npm start"

View File

@@ -78,5 +78,5 @@ test('runSource treats disabled source status as degraded health', async () => {
const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' })); const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' }));
assert.equal(result.status, 'degraded'); assert.equal(result.status, 'degraded');
assert.equal(result.error, null); assert.equal(result.error, 'missing key');
}); });

View File

@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('BLS falls back to unauthenticated v1 when configured key is invalid', async () => {
const originalFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (url) => {
urls.push(String(url));
if (String(url).includes('/v2/')) {
return {
json: async () => ({
status: 'REQUEST_NOT_PROCESSED',
message: ['The key:test-key provided by the User is invalid.'],
}),
};
}
return {
json: async () => ({
status: 'REQUEST_SUCCEEDED',
Results: {
series: [{
seriesID: 'CUUR0000SA0',
data: [{ year: '2026', period: 'M06', value: '320.1' }],
}],
},
}),
};
};
try {
const { briefing } = await import('../apis/sources/bls.mjs');
const data = await briefing('test-key');
assert.equal(data.status, 'degraded');
assert.match(data.configWarning, /retried with unauthenticated/);
assert.equal(data.indicators[0].value, 320.1);
assert.ok(urls.some(url => url.includes('/v2/')));
assert.ok(urls.some(url => url.includes('/v1/')));
} finally {
globalThis.fetch = originalFetch;
}
});
test('runSource exposes degraded warnings in timing health', async () => {
const { runSource } = await import('../apis/briefing.mjs');
const result = await runSource('BLS', async () => ({
source: 'BLS',
status: 'degraded',
configWarning: 'Configured key rejected; fallback used.',
indicators: [],
}));
assert.equal(result.status, 'degraded');
assert.equal(result.error, 'Configured key rejected; fallback used.');
});
test('OFAC metadata uses bounded XML snippets', async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, options = {}) => {
requests.push({ url: String(url), range: options.headers?.Range });
const xml = '<sdnList><publshInformation><Publish_Date>07/07/2026</Publish_Date><Record_Count>123</Record_Count></publshInformation><sdnEntry><uid>1</uid><firstName>Jane</firstName><lastName>Example</lastName><sdnType>Individual</sdnType><program>TEST</program></sdnEntry></sdnList>';
return new Response(xml, {
status: 206,
headers: { 'Content-Type': 'application/xml' },
});
};
try {
const { briefing } = await import('../apis/sources/ofac.mjs');
const data = await briefing();
assert.equal(data.sdnList.publishDate, '07/07/2026');
assert.equal(data.sdnList.recordCount, 123);
assert.equal(data.sampleEntries[0].name, 'Jane Example');
assert.ok(requests.every(request => /^bytes=0-/.test(request.range)));
} finally {
globalThis.fetch = originalFetch;
}
});